diff --git a/docs/port/DECISIONS.md b/docs/port/DECISIONS.md index 3f483f64..9aa8fba9 100644 --- a/docs/port/DECISIONS.md +++ b/docs/port/DECISIONS.md @@ -9,7 +9,7 @@ dies, which is what this file is for. -276 sections. Search this before re-deriving anything. +278 sections. Search this before re-deriving anything. * [P0 — the exporter, 2026-08-28](#p0--the-exporter-2026-08-28) * [P1 — Godot draws the screen, 2026-08-28](#p1--godot-draws-the-screen-2026-08-28) @@ -287,6 +287,8 @@ dies, which is what this file is for. * [Menu focus does not survive a reboot — and the reach matters more than the result](#menu-focus-does-not-survive-a-reboot--and-the-reach-matters-more-than-the-result) * [Liveness: every one of my tools passed on an empty input](#liveness-every-one-of-my-tools-passed-on-an-empty-input) * [Their `ring_row.py` defect, and why it did not reach me](#their-ring_rowpy-defect-and-why-it-did-not-reach-me) +* [The liveness lesson, applied to the product: a mistyped override was silent](#the-liveness-lesson-applied-to-the-product-a-mistyped-override-was-silent) +* [Their P3 delivery, taken at the strength they gave it](#their-p3-delivery-taken-at-the-strength-they-gave-it) ## P0 — the exporter, 2026-08-28 @@ -13966,3 +13968,65 @@ starts mattering. reason to. Several of my authored values rest on them; what I have instead is their statement of the exposure and the structural argument for why it did not fire. Recorded as that, not as verification. + +## The liveness lesson, applied to the product: a mistyped override was silent + +Every checker fix this week has been about a tool that could not tell *"I checked +and it was fine"* from *"I checked nothing"*. **The port itself had the same +defect, facing the person the asset tree exists for.** + +`ExportTree.resolve` announces every shadow as it happens, and its comment already +records why a startup summary was wrong. **Nothing reported the opposite.** +Measured with two planted overrides — one correct, one in a mistyped directory: + +``` +mod: sprites/title/main_menu/ptbase.png <- data/mods/... ← announced +sprites/title/TYPO_menu/pteff05.png ← NO OUTPUT AT ALL +``` + +The modder sees the port load, run, and say nothing about the file that did +nothing. **That is MODDING rule 4's own failure mode**: base-and-overrides is only +usable if an override that misses says so. + +`ExportTree.unused_mods()` + a report at run end now lists them. Controlled both +directions: **one inert file with the typo present, silent with it removed.** + +### 🔴 Getting the report's *category* right took three tries, and that is the point + +* **v1 — "never used".** Flagged `data/mods/README.md` on every run. **A report + with a standing false positive is one nobody reads**, which is precisely the + failure it exists to fix. +* **v2 — "no such path in the export".** Correct, and still flagged the README: + it genuinely cannot shadow anything. +* **v3 — excluded by extension, with the rule checked rather than assumed.** The + export tree contains only `png`, `json`, `ogg`, `ogv`, `cmd` — **verified, zero + `.md` anywhere** — so a `.md` in `data/mods` could never be an override *by + construction*. Flagging a class that can never be one is noise. + +📌 And the report distinguishes two things v1 conflated: a file whose path exists +in the export but **was not read this run** (a `--menu` run touches one screen) is +**not listed**. Every line printed is an override that can never apply, whatever +the run does. + +⚠️ One incident worth keeping: `boot.gd` **already had an `_exit_tree`**, and +adding a second was a **parse error** — the run failed loudly instead of one hook +silently replacing the other. The cheapest possible failure mode, and only +because GDScript happens to reject it. + +## Their P3 delivery, taken at the strength they gave it + +Q6's count-match now has **disc support for its structure**: every button record +across all 16 `GP_TITLE` entries is `ptbtn00`, `ptbtn01–05`, `ptbtn11–13` — three +button screens and no fourth, with the other four destinations in their own paks. + +⚠️ **Not authored from, and they said not to.** *"It shows the shape the +count-match asserts is real on the disc; it does not show that event 3 is a +particular row."* My `flow.json` already binds buttons to destinations by +measured screen rather than by event index, so nothing here changes — and if a +button-to-event map were ever needed, **there is not one**. + +📌 Their negative carries its own reach, which is the part I would have got wrong: +they searched every pak for an 8-button-record build and found none, but the +search assumed DIFFICULTY's four items pair with `f` variants as `GP_TITLE`'s +screens do. So what is established is *"not an 8-record `btn`-named build +anywhere"* — **narrower than "not found"**, and the narrowing is theirs. diff --git a/port/scripts/boot.gd b/port/scripts/boot.gd index 63d0b3b9..aa7590bd 100644 --- a/port/scripts/boot.gd +++ b/port/scripts/boot.gd @@ -104,6 +104,7 @@ func _ready() -> void: get_tree().quit(4) return var export_tree := ExportTree.locate() + _tree = export_tree if export_tree.root == "": push_error(export_tree.error) get_tree().quit(2) @@ -735,6 +736,33 @@ func _video_finished() -> void: var _video_then: Dictionary = {} +## Say what the mods directory contributed, and what it did not. +## +## The shadow lines are printed as they happen; this is the other half -- files +## that matched nothing. Without it a mistyped override is indistinguishable +## from a working one to the person who wrote it, which makes the whole +## base-and-overrides arrangement (MODDING rule 4) unusable by its own audience. +## The export tree, kept so `_exit_tree` can report what the mods directory did +## NOT contribute. It was a local in `_ready`, which is why the unused-override +## report could not exist until now. +var _tree: ExportTree = null + + +func _report_unused_mods() -> void: + if _tree == null: + return + var unused := _tree.unused_mods() + if unused.is_empty(): + return + print("mods: %d file(s) in data/mods can shadow NOTHING -- no such path in the" + % unused.size() + " export:") + for rel in unused: + print(" inert: %s" % rel) + print(" ⚠️ These are not \"not reached yet\": a file whose path exists in the") + print(" export but was not read this run is NOT listed. Every line above is") + print(" an override that can never apply, whatever the run does.") + + func _unhandled_input(event: InputEvent) -> void: # HANDOFF Q9, measured: one (A) press skips a movie -- the title was reached # at 57 s against a 193 s baseline. @@ -1204,6 +1232,10 @@ func _start_recording() -> void: func _exit_tree() -> void: + # 🔴 boot.gd ALREADY HAD an `_exit_tree`, and adding a second was a parse + # error rather than a silent override -- the one failure mode that costs + # nothing. The mods report hangs off the existing hook. + _report_unused_mods() if _record == null: return _record.set_recording_active(false) diff --git a/port/scripts/export_tree.gd b/port/scripts/export_tree.gd index 1a2d17b3..6d1e5a20 100644 --- a/port/scripts/export_tree.gd +++ b/port/scripts/export_tree.gd @@ -80,6 +80,70 @@ func resolve(rel: String) -> String: return root.path_join(rel) +## Mod files that were never used, listed at the end of a run. +## +## 🔴 A MISTYPED OVERRIDE WAS SILENT. `resolve` announces every shadow as it +## happens -- that half was already right, and its comment records why a startup +## summary was wrong. What nothing reported was the opposite: a file sitting in +## `data/mods/` whose path matches no asset. Measured: `sprites/title/main_menu/` +## is announced, `sprites/title/TYPO_menu/` produces **no output at all**. The +## modder sees the port load, run, and say nothing about the file that did +## nothing. +## +## That is MODDING rule 4's own failure mode -- base-and-overrides is only usable +## if an override that misses says so -- and it is the same shape as the +## checkers that passed on an empty input: **agreeable rather than wrong.** A +## port that cannot tell "your override is in effect" from "your override was +## never looked at" is unusable for the person the asset tree exists for. +## +## ⚠️ Reported at the END of a run, not at startup: resolution is lazy, so before +## the assets are read there is nothing to compare against. A run that quits +## early will list files a longer run would have used, and the wording says so +## rather than calling them errors. +func unused_mods() -> PackedStringArray: + var out: PackedStringArray = [] + if mods == "": + return out + var stack: PackedStringArray = [""] + while not stack.is_empty(): + var rel := stack[stack.size() - 1] + stack.remove_at(stack.size() - 1) + var dir := DirAccess.open(mods.path_join(rel)) + if dir == null: + continue + dir.list_dir_begin() + var name := dir.get_next() + while name != "": + var child := rel.path_join(name) if rel != "" else name + if dir.current_is_dir(): + stack.append(child) + elif not shadowed.has(child): + # 🔴 TWO DIFFERENT THINGS, and reporting them as one produced a + # permanent false positive on the mods directory's own README. + # A file whose path exists in `export/` was simply not read this + # run -- a `--menu` run touches one screen. A file whose path + # exists NOWHERE in the export can never shadow anything: that + # is the mistyped override, and it is the only one that is a + # defect. A report with a standing false positive becomes + # scenery, which is the failure this whole report exists to fix. + # ⚠️ And a THIRD category, excluded by extension with the rule + # stated rather than assumed: the export tree contains only + # `png`, `json`, `ogg`, `ogv` and `cmd` files -- checked, no + # `.md` anywhere -- so a `.md` in `data/mods` cannot shadow + # anything BY CONSTRUCTION and is documentation, not a failed + # override. Flagging a class that could never be an override is + # noise, and a report with a permanent false positive is one + # nobody reads. `data/mods/README.md` is the standing case. + var ext := child.get_extension().to_lower() + if ext in ["png", "json", "ogg", "ogv", "cmd"] \ + and not FileAccess.file_exists(root.path_join(child)): + out.append(child) + name = dir.get_next() + dir.list_dir_end() + out.sort() + return out + + # `authored/` sits beside `export/`, never inside it: it is hand-written and # committed, and a re-export must not be able to touch it. func authored(name: String) -> Variant: