Add night dressing and end cards; fix the hide_render bug

hide_render does not survive glTF export — verified in three r175, every node
arrives visible:true. So garden_bed has been drawing plants_full, tattered AND
dead superimposed since Sprint 1, and my THREADS note told Lane A the opposite.
The asserts missed it by checking the nodes existed rather than that only one
was on. The flag now rides in extras, which does export, and consumers apply it
in one line; e.test.js pins the flag instead of the node.

Night dressing: house_yardside gains window_glow, an unlit emissive pane marked
hidden_by_default, plus window_light_anchor. A warm window means someone is
inside, which is why the player is out in the dark keeping the sail on. Plus a
moon sprite whose halo, not its disc, is the part that survives cloud.

End cards are rendered from the game's own props as a diptych: identical camera
and yard, only the night's outcome differs. The left third is left empty for
Lane A's text.

Cards are opt-in behind --cards because EEVEE's shadow pass is not
byte-reproducible across processes in 5.1 — proven with a minimal repro, not
PYTHONHASHSEED, and present even with a hard sun. The dawn shadow is the art, so
the cards become committed art rather than build output. Everything the game
loads stays byte-identical: 38/38 verified.

Selftest 241/0/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-17 09:28:26 +10:00
parent 125411e0df
commit 08a1d95903
8 changed files with 308 additions and 5 deletions

View File

@ -1802,3 +1802,62 @@ Format: `[lane letter] YYYY-MM-DD — note`
flagged exactly this joint call when holding 0.45 — but it is now measured and it is SPRINT6 gate 1.
Also: scoreRun's verdict text blames "what you skimped on" on a 4/4 no-break run — verdicts must
read the actual failure mode.
[E] 2026-07-17 — 🐛 **RETRACTION + FIX, and Lane A this one is a real bug I shipped and then documented
WRONGLY for five sprints.** I told you garden_bed ships "full visible, rest `hide_render`". That is
false. **glTF has no node-visibility flag and Blender's `hide_render` does not survive the export**
verified in three r175: `plants_full`, `plants_tattered` and `plants_dead` all arrive `visible: true`.
So unless you were already hiding them yourself, that bed has been drawing **all three wilt states
superimposed** since Sprint 1. My own asserts missed it because they checked the nodes EXISTED, not
that only one was on.
Fixed at source the only way that survives: the flag now rides in extras (userData does export —
that much I did verify last sprint). **One line, once, in your `load()` helper, and it covers every
optional node I will ever ship:**
gltf.scene.traverse((o) => { if (o.userData?.hidden_by_default) o.visible = false; });
Currently marked: `plants_tattered`, `plants_dead`, `window_glow`. e.test.js now asserts the FLAG,
not the node. Sorry — that one's on me.
[E] 2026-07-17 — **night dressing (SPRINT6 §E-1), both for the night storms:**
· `house_yardside_v1.glb` gains **`window_glow`** — an unlit emissive pane (needs no light, costs
nothing while off) shipping `hidden_by_default`, plus a `window_light_anchor` empty if you want a
warm ~2700 K PointLight spilling onto the grass under the eave. Flip it on for storm_02/03.
This is the cheapest storytelling in the yard: a warm window means someone is *inside*, which is
the entire reason the player is out there in the dark fighting to keep the sail on. An empty yard
at night is just weather.
· `models/textures/moon.png` — 256², disc + halo, alpha. **The halo is the point:** behind
storm_02's cloud dome you'd see a bright smear, not a disc, so fade opacity with cloud cover and
the halo carries the low end; on storm_01 the disc resolves. Lane C, it's a sprite on the dome —
ignore it if the sky doesn't want one, no feelings.
[E] 2026-07-17 — **end cards (§E-2): `models/textures/card_win.jpg` + `card_gameover.jpg`, 1200×675.**
Rendered from the game's OWN props rather than drawn — a gradient with a font on it says nothing; the
gnome you failed to protect, lying in pieces exactly where he stood, says it without a word. They're a
**diptych: identical camera, identical yard, and the only thing that changed is what the night did to
it** — gnome standing in dawn light with a long shadow vs. in bits under flat grey, fence whole vs.
holed with palings on the grass, bed green vs. dead. A player who sees both reads the difference
faster than any headline. **The left third is deliberately empty — that's yours for the text**, Lane A,
and the files are plain JPEGs so do whatever you like on top.
[E] 2026-07-17 — ⚠️ **worth knowing if you ever render from Blender: EEVEE's SHADOW pass is not
byte-reproducible across processes in 5.1.** Two identical runs of an identical scene give different
pixels. I chased this properly because the cards broke my determinism check: it is NOT PYTHONHASHSEED
(6/6 identical with randomisation live), and it happens even with a hard 0° sun — minimal repro is a
cube, a plane and a sun. The contact sheet dodges it only because thumbnails have no ground plane to
receive a shadow (still byte-identical, 3/3 — that Sprint 1 claim stands).
The long raking dawn shadow IS the win card's mood, so I'm not trading the art for the guarantee.
Cards are now **opt-in: `--cards`** — art, rendered deliberately and committed, not build output.
Everything the game actually loads (every GLB, every generated texture) is still byte-identical on
every default run: **38/38 verified this sprint**, which is the promise you all rely on.
Also, my own earlier check was lying to me: `ls a/*.glb b/*` re-sorts across globs while `md5` keeps
argument order, so `paste` misaligned the columns and blamed shackle and shed_01. If you diff hashes,
tag them with their filename.
[E] 2026-07-17 — §E-3 contact sheets refreshed (21 assets, 4×6). The **yard shots stay as they are** for
now: `docs/yard_day.jpg` / `yard_night.jpg` are pre-balance and unrigged. Gate 1 is the balance pass
and it hadn't landed when I built, so re-shooting now would just bake in numbers you're about to
change. **Ping me when gate 1 is green and I'll reshoot both with a winnable rig actually in frame**
`tools/yardshot/shot_server.py` makes it a one-minute job now. (Lane A: your SPRINT6 list says lift
`do_POST` into server.py and delete `tools/yardshot/` — the whole fix is ~25 lines, take it whenever.)
[E] 2026-07-17 — grass_atlas: still 0 refs, five sprints. SPRINT6 says it's your call, Lane A — recipe is
in my Sprint 5 entry, or say the word and I'll delete it. Either is fine; carrying it isn't.

View File

@ -11,6 +11,7 @@ Run:
blender -b -P tools/blender/build_yard_assets.py -- --only tree_gum_01
blender -b -P tools/blender/build_yard_assets.py -- --no-verify
blender -b -P tools/blender/build_yard_assets.py -- --no-debris
blender -b -P tools/blender/build_yard_assets.py -- --cards # re-render the end cards
Outputs (resolved from this file, so no absolute home paths):
web/world/models/*.glb nature, hardware, ref_capsule
@ -119,6 +120,7 @@ PAL = {
"gnome_hat": "#B33C36",
"bristle": "#C9A659", # broom straw
"hail_ice": "#DCEAF2", # hailstone
"window_warm": "#FFC98A", # someone is home
"ref_pink": "#E85C8A", # the reference capsule — deliberately loud
}
@ -165,6 +167,40 @@ def get_material(name, color_hex, roughness=0.7, metallic=0.0, opacity=1.0):
return mat
def hide_by_default(obj):
"""Mark a node as "present but off until the game says otherwise".
glTF has NO standard node-visibility flag, and Blender's `hide_render` does
not survive the export verified in three r175: every node arrives
`visible: true`. This shipped as a real bug from Sprint 1 to Sprint 6, with
garden_bed rendering plants_full, plants_tattered AND plants_dead
superimposed, and my own THREADS note claiming the opposite.
userData DOES survive, so the flag rides in extras and the consumer applies
it in one line (see THREADS [E]):
gltf.scene.traverse(o => { if (o.userData?.hidden_by_default) o.visible = false; });
hide_render is still set so Blender's own contact-sheet renders match the game.
"""
obj["hidden_by_default"] = True
obj.hide_render = True
return obj
def _emissive(mat, color_hex, strength=1.0):
"""Emissive on the Principled BSDF. glTF carries it as emissiveFactor (plus
KHR_materials_emissive_strength above 1.0), so the pane reads with no light
in the scene at all which is the point on a black storm night."""
bsdf = mat.node_tree.nodes.get("Principled BSDF")
if not bsdf:
return mat
if "Emission Color" in bsdf.inputs:
bsdf.inputs["Emission Color"].default_value = hex_to_rgba(color_hex)
if "Emission Strength" in bsdf.inputs:
bsdf.inputs["Emission Strength"].default_value = strength
return mat
def deselect_all_no_ops():
"""Avoid bpy.ops.object.select_all — works without a proper context."""
for o in bpy.data.objects:
@ -672,6 +708,24 @@ def build_house_yardside(name):
(win_x, -D / 2 + 0.02, win_z + win_h / 2), trim)],
"window", root)
# Night dressing (SPRINT6 §Lane E-1). `window_glow` ships HIDDEN — Lane A
# flips .visible on the night storms. It's an unlit emissive pane, so it
# needs no light to read and costs nothing when it's off.
#
# This is the cheapest storytelling in the yard: a warm window means someone
# is inside, which is the whole reason the player is out here in the dark
# fighting to keep the sail on. An empty yard at night is just weather.
glow_m = get_material("Mat_WindowGlow", PAL["window_warm"], 1.0)
_emissive(glow_m, PAL["window_warm"], 2.4)
glow = add_box("window_glow", (win_w - 0.10, 0.01, win_h - 0.10),
(win_x, -D / 2 + 0.055, win_z + win_h / 2), glow_m,
parent=root)
hide_by_default(glow)
glow["night_only"] = True
e = add_empty("window_light_anchor", (win_x, -D / 2 - 0.35, win_z + win_h / 2),
root, size=0.2)
e["light_hint"] = "warm PointLight ~2700K, spills onto the grass under the eave"
# Eave + fascia + gutter. The eave overhangs 0.55 into the yard (-Y).
eave_y = -0.55
fascia_z = H + 0.10
@ -819,8 +873,9 @@ def build_garden_bed(name):
node = join_group(parts, f"plants_{state}", root)
if node:
node["damage_state"] = state
# Only `full` ships visible; Lane A swaps by toggling these.
node.hide_render = (state != "full")
# Only `full` starts on; Lane A swaps by toggling .visible.
if state != "full":
hide_by_default(node)
stamp(root, name, "garden")
root["states"] = "full,tattered,dead"
root["bed_size"] = f"{W}x{D}"
@ -1622,6 +1677,167 @@ def build_hail_and_shred_atlases():
return [p1, p2]
def build_moon_texture():
"""Moon disc + halo for the night storms (SPRINT6 §Lane E-1).
The halo is the point, not the disc. On storm_02 the moon sits behind a
cloud dome and what you'd actually see is a bright smear; on storm_01 the
disc resolves. One sprite does both Lane C fades opacity with cloud cover
and the halo carries the low end.
"""
import numpy as np
SIZE = 256
Y, X = np.mgrid[0:SIZE, 0:SIZE]
c = (SIZE - 1) / 2.0
nx, ny = (X - c) / c, (Y - c) / c
r = np.sqrt(nx * nx + ny * ny)
R_DISC = 0.34
disc = np.clip((R_DISC - r) / 0.02, 0, 1) # soft-edged disc
halo = np.exp(-((r - R_DISC) ** 2) / 0.045) * 0.55 # the bit that survives cloud
halo = np.where(r > R_DISC, halo, 0.0)
# Maria: a few soft dark blotches, seeded. Craters at this size are a lie —
# what you see from a back yard is patches.
shade = np.ones_like(r)
mrng = rng_for("moon_maria")
for _ in range(6):
mx, my = mrng.uniform(-0.18, 0.18), mrng.uniform(-0.18, 0.18)
mr = mrng.uniform(0.06, 0.13)
d = np.sqrt((nx - mx) ** 2 + (ny - my) ** 2)
shade -= np.exp(-(d ** 2) / (mr ** 2)) * mrng.uniform(0.06, 0.13)
shade = np.clip(shade, 0.72, 1.0)
a = np.clip(disc + halo, 0, 1)
lum = np.clip(np.where(disc > 0, shade, 1.0), 0, 1)
img = np.zeros((SIZE, SIZE, 4), dtype=np.float32)
img[:, :, 0] = lum * 0.96
img[:, :, 1] = lum * 0.97
img[:, :, 2] = lum
img[:, :, 3] = a
p, kb = save_png(img, "moon")
print(f" moon.png {SIZE}x{SIZE}, disc+halo, {kb} KB")
return p
# ============================================================================
# END CARDS — rendered from the game's own props, not drawn (SPRINT6 §Lane E-2)
# ============================================================================
def _card_scene(broken):
"""One vignette, built from the same yard objects the player spent the week
protecting that's the whole idea. A gradient with a font on it says
nothing; the gnome you failed to protect, lying in pieces, says it without a
word."""
reset_to_empty()
grass = get_material("Mat_Grass", "#4A6B36" if broken else "#5C8A3A", 1.0)
add_box("ground", (60, 60, 0.4), (0, 0, -0.2), grass)
if broken:
gnome = build_garden_gnome_01_broken("gnome")
gnome.location = (0.82, -0.78, 0)
gnome.rotation_mode = 'XYZ'
gnome.rotation_euler = (0, 0, math.radians(-24))
fence = build_fence_panel_snapped("fence")
fence.location = (-0.35, 2.6, 0)
bin_ = build_wheelie_bin_01("bin") # blown over, lid flung open
bin_.location = (2.9, 1.5, 0.34)
bin_.rotation_mode = 'XYZ'
bin_.rotation_euler = (math.radians(-96), 0, math.radians(38))
bed = build_garden_bed("bed")
bed.location = (-3.4, 1.2, 0)
for o in bpy.data.objects: # a loss shows the dead bed
if o.name == "plants_full":
o.hide_render = True
elif o.name == "plants_dead":
o.hide_render = False
else:
gnome = build_garden_gnome_01("gnome")
gnome.location = (0.82, -0.78, 0)
fence = build_fence_panel("fence")
fence.location = (-0.35, 2.6, 0)
post = build_fence_post("post")
post.location = (0.85, 2.6, 0)
bed = build_garden_bed("bed")
bed.location = (-3.4, 1.2, 0)
shed = build_shed_01("shed")
shed.location = (3.6, 2.2, 0)
def build_end_cards():
"""NOT part of the default build — pass `--cards`.
EEVEE's SHADOW rendering is not byte-reproducible across processes in
Blender 5.1: two identical runs of the same scene give slightly different
pixels. Measured, with a minimal cube-plus-plane repro, and it happens even
with a hard (0-degree) sun. The contact sheet escapes it only because its
thumbnails have no ground plane to receive a shadow verified still
byte-identical 3/3.
The long raking dawn shadow IS the win card's mood, so dropping shadows to
win determinism would be trading the art for the guarantee. Instead these
are treated as what they are: art, rendered deliberately and committed, not
build output. Everything the game actually loads every GLB and every
generated texture stays byte-identical on every run, which is the promise
other lanes rely on.
"""
out = []
for name, broken, warm in (("card_win", False, True),
("card_gameover", True, False)):
_card_scene(broken)
scn = bpy.context.scene
scn.render.engine = 'BLENDER_EEVEE'
scn.render.resolution_x, scn.render.resolution_y = 1200, 675
scn.render.image_settings.file_format = 'JPEG'
scn.render.image_settings.quality = 88
scn.world = bpy.data.worlds.new(f"W_{name}")
scn.world.use_nodes = True
bg = scn.world.node_tree.nodes.get("Background")
if bg:
# Dawn after a night you survived, vs the flat grey of a morning you
# have to explain to a client.
bg.inputs[0].default_value = ((0.92, 0.55, 0.32, 1.0) if warm
else (0.34, 0.37, 0.41, 1.0))
# Low strength on purpose. The world is a huge ambient fill, and at
# 1.15 it flooded both cards flat — the sun stopped casting anything
# and dawn read as fog. Keep the fill down and let the sun do it.
bg.inputs[1].default_value = 0.30 if warm else 0.42
bpy.ops.object.light_add(type='SUN', location=(-6, -8, 4))
sun = _active()
sun.data.energy = 7.0 if warm else 2.6
sun.data.angle = math.radians(2 if warm else 40) # crisp dawn vs overcast
sun.data.color = (1.0, 0.78, 0.52) if warm else (0.82, 0.86, 0.92)
sun.rotation_mode = 'XYZ'
# Low and raking on the win card: the long shadows are the mood.
sun.rotation_euler = ((math.radians(74), 0, math.radians(-52)) if warm
else (math.radians(28), 0, math.radians(30)))
bpy.ops.object.camera_add(location=(0, 0, 0))
cam = _active()
cam.data.lens = 50
scn.camera = cam
# IDENTICAL camera on both cards, deliberately. They're a diptych: same
# yard, same framing, and the only thing that changed overnight is what
# happened to it. A player who sees both reads the difference instantly,
# which no amount of headline text would do as fast. Low and close, so
# the gnome is the subject; the left third stays empty for Lane A's text.
cam.location = (2.00, -2.86, 0.94)
cam.rotation_mode = 'XYZ'
target = Vector((0.66, -0.42, 0.30))
cam.rotation_euler = (target - cam.location).to_track_quat('-Z', 'Y').to_euler()
os.makedirs(TEXTURES_DIR, exist_ok=True)
out_path = os.path.join(TEXTURES_DIR, f"{name}.jpg")
scn.render.filepath = out_path
bpy.ops.render.render(write_still=True)
kb = os.path.getsize(out_path) // 1024
print(f" {name}.jpg{' ' * max(1, 18 - len(name))}1200x675, from props, {kb} KB")
out.append(out_path)
reset_to_empty()
return out
def build_grass_atlas():
"""4-tuft billboard atlas, 2x2 cells. Drawn with numpy (no PIL in Blender's
python) and saved through bpy's image API. Lane A instances quads with this."""
@ -1696,6 +1912,7 @@ ASSETS = [
dict(name="house_yardside", fn=build_house_yardside,
dims=((9.0, 9.5), (0.8, 1.6), (2.8, 3.3)),
nodes=["wall", "door", "window", "roof", "fascia", "gutter",
"window_glow", "window_light_anchor",
"fascia_anchor_01", "fascia_anchor_02", "fascia_anchor_03"]),
dict(name="shed_01", fn=build_shed_01,
dims=((2.4, 2.7), (1.8, 2.1), (1.95, 2.25)),
@ -2058,18 +2275,20 @@ def make_contact_sheet(thumbs):
def parse_args():
argv = sys.argv
argv = argv[argv.index("--") + 1:] if "--" in argv else []
only, no_verify, no_debris = None, False, False
only, no_verify, no_debris, cards = None, False, False, False
if "--only" in argv:
only = set(argv[argv.index("--only") + 1].split(","))
if "--no-verify" in argv:
no_verify = True
if "--no-debris" in argv:
no_debris = True
return only, no_verify, no_debris
if "--cards" in argv:
cards = True
return only, no_verify, no_debris, cards
def main():
only, no_verify, no_debris = parse_args()
only, no_verify, no_debris, cards = parse_args()
print("\n" + "=" * 72)
print("SHADES yard asset factory — Lane E")
print(f"Blender {bpy.app.version_string} repo: {REPO_ROOT}")
@ -2081,6 +2300,9 @@ def main():
build_sail_textures()
build_pond_textures()
build_hail_and_shred_atlases()
build_moon_texture()
if cards:
build_end_cards()
debris = [] if no_debris else copy_debris()
failures = []

View File

@ -280,6 +280,28 @@ export default async function run(t) {
}
});
// glTF has no node-visibility flag and Blender's hide_render does NOT survive
// the export — every node arrives visible:true. That shipped as a real bug
// from Sprint 1 to Sprint 6: garden_bed drew full + tattered + dead
// superimposed, and the asserts missed it because they only checked the nodes
// EXISTED. So the flag rides in extras, and this pins the flag, not the node.
t.test('optional nodes carry hidden_by_default — hide_render does not export', () => {
const bed = loaded.get('garden_bed')?.scene;
assert(bed, 'garden_bed did not load');
const on = ['plants_full'], off = ['plants_tattered', 'plants_dead'];
for (const n of on) {
assert(!bed.getObjectByName(n).userData?.hidden_by_default,
`${n} should start visible — it's the default state`);
}
for (const n of off) {
assert(bed.getObjectByName(n).userData?.hidden_by_default === true,
`${n} must carry hidden_by_default or the bed renders all three wilt states at once`);
}
const glow = loaded.get('house_yardside')?.scene.getObjectByName('window_glow');
assert(glow?.userData?.hidden_by_default === true,
'window_glow must carry hidden_by_default or the house is lit at noon');
});
// One GLB carries three wilt states as siblings; Lane A toggles .visible
// rather than reloading, so all three have to be present at once.
t.test('garden_bed carries all 3 damage states in one GLB', () => {

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB