MODDING.md calls base-and-overrides "a design constraint on the exporter today, not a milestone to add later". Nothing read `data/mods/` at all -- the directory has existed since the monorepo merge with a .gitkeep and no code path anywhere. Eight milestones shipped past it. ExportTree.resolve() now shadows by path, and every read goes through it: screens, sprites, cues, the music bed, movies. MenuAudio was reading tree.root directly and would otherwise have made audio the one asset kind a mod could not touch, for no reason a modder could have guessed. No manifest, no registration step -- the path IS the registration, which is the whole of the rule. One tree, not a stack: layering needs a load order and nobody has asked for one, so data/mods/README.md says that rather than inventing it. Every shadowed file is printed as it is read. The first version summarised in _ready, before any asset had been read, so it always said "nothing shadowed yet" -- a report structurally incapable of reporting anything, which is worse than none because it looks like an answer. data/mods/ was NOT gitignored, and that is a hole in a hard rule: a mod is usually an edited game asset, and this was the one directory a user is invited to put modified sprites in and git would have taken them. Now excluded except the README. Gate: a synthetic 203x43 magenta PNG (nothing disc-derived) at data/mods/sprites/title/main_menu/ptbtn01.png changes 8501 pixels in a bounding box of exactly 203x43 at the button's position, and `check` still passes. RAISED, NOT RESOLVED: MODDING.md says the tree is data/base/, PORT-MISSION.md §3 and the exporter and .gitignore say export/. Both are mission files and only the human changes a mission. REFUTATION on Q3's paint-order key: 2 of 16 screens did not match a stable sort by layer key -- but that was my test. pgloading_eff00.prm carries NO layer key (layer_source "none"): a primitive with no sprite header and no implied-name fallback. I sorted keyless first; the decoders put it last, which is right, since it is the full-screen black quad and HANDOFF's own sentence is that the fade quad paints last. Completing the rule to "keyless last" gives 16 of 16. SURVIVES. Recorded because the published claim does not say where a keyless element goes and there is one in the archive. Separately the tie-break's reach looks understated: 105 elements share a layer key across 12 of 16 screens, where HANDOFF characterises the cost as "one element's blend on one screen". WITHDRAWN, and it was mine: I filed "the runtime mix has no headroom" in red twice, off a peak reading. Measured properly it is 43 samples at full scale in 5.9 s and 24 in 98.5 s, longest run 0.25 ms -- the disc's own confirm cue on a transient, possibly only in the 16-bit save. Nothing changed, deliberately: attenuating would be an unmeasured level decision of the kind I refused for the loop point. A peak reading is not a clipping measurement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WM5XL4HfrHuxz8RiMWdCMC
184 lines
6.8 KiB
GDScript
184 lines
6.8 KiB
GDScript
# Locating and reading the open export tree.
|
|
#
|
|
# The Godot project NEVER reads a disc format (docs/MISSION.md §2). Everything
|
|
# it draws comes from `export/`, which is derived, gitignored and regenerated
|
|
# wholesale by `crates/sylpheed-export`. This class is the only place that knows
|
|
# where that tree is on disk.
|
|
class_name ExportTree
|
|
extends RefCounted
|
|
|
|
const FORMAT_SCREEN := "sylpheed.screen/3"
|
|
const FORMAT_MANIFEST := "sylpheed.manifest/1"
|
|
|
|
var root: String = ""
|
|
## The override tree, or "" when there is none. MODDING rule 4: a mod replaces a
|
|
## file by SHADOWING ITS PATH, so `mods/screens/title/main_menu.json` stands in
|
|
## for `<root>/screens/title/main_menu.json` and nothing under the derived tree
|
|
## is touched. That is what makes re-exporting always safe.
|
|
var mods: String = ""
|
|
## Relative paths a mod actually replaced this run, in the order they were first
|
|
## read. Recorded because MODDING says "did I break it?" is answered by disabling
|
|
## a mod -- which only works if a modded run does not look like an unmodded one.
|
|
var shadowed: Array[String] = []
|
|
var error: String = ""
|
|
|
|
|
|
# `SYLPHEED_EXPORT` wins, so a modder can point the game at their own tree
|
|
# without touching the project. Otherwise `<project>/../export`, which is the
|
|
# layout this repository has.
|
|
static func locate() -> ExportTree:
|
|
var t := ExportTree.new()
|
|
var env := OS.get_environment("SYLPHEED_EXPORT")
|
|
var candidate := env
|
|
if candidate == "":
|
|
candidate = ProjectSettings.globalize_path("res://").path_join("../export").simplify_path()
|
|
if not FileAccess.file_exists(candidate.path_join("manifest.json")):
|
|
t.error = "no manifest.json under %s -- run `sylpheed-export` first" % candidate
|
|
return t
|
|
t.root = candidate
|
|
|
|
# The override tree. `SYLPHEED_MODS` wins for the same reason
|
|
# `SYLPHEED_EXPORT` does; otherwise `data/mods/`, which is the directory
|
|
# MODDING.md's own layout diagram names and the one this repository ships.
|
|
#
|
|
# Absent is normal and silent: an unmodded run is the common case, and a
|
|
# warning about a directory nobody created would be noise.
|
|
var m := OS.get_environment("SYLPHEED_MODS")
|
|
if m == "":
|
|
m = ProjectSettings.globalize_path("res://").path_join("../data/mods").simplify_path()
|
|
if DirAccess.dir_exists_absolute(m):
|
|
t.mods = m
|
|
return t
|
|
|
|
|
|
## Where a relative path actually comes from: the mod tree if it has one, else
|
|
## the derived tree.
|
|
##
|
|
## Every read in this class goes through here, so a mod can replace a screen's
|
|
## JSON, a sprite, a cue, a music bed or a movie by dropping a file at the same
|
|
## relative path. There is deliberately no manifest of what a mod contains and no
|
|
## registration step -- the path IS the registration, which is the whole of
|
|
## MODDING rule 4.
|
|
##
|
|
## ⚠️ One tree, not a stack. Several mods layering over each other needs an
|
|
## order, and an order needs a rule nobody has asked for yet. Say so rather than
|
|
## invent one.
|
|
func resolve(rel: String) -> String:
|
|
if mods != "":
|
|
var over := mods.path_join(rel)
|
|
if FileAccess.file_exists(over):
|
|
if not shadowed.has(rel):
|
|
shadowed.append(rel)
|
|
# Announced the moment it happens, not summarised at startup.
|
|
# The first version printed a summary in `_ready`, before a
|
|
# single asset had been read, so it always said "nothing
|
|
# shadowed yet" -- a report that is structurally incapable of
|
|
# reporting anything is worse than none, because it looks like
|
|
# an answer.
|
|
print("mod: %s <- %s" % [rel, over])
|
|
return over
|
|
return root.path_join(rel)
|
|
|
|
|
|
# `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:
|
|
var path := root.path_join("../authored").simplify_path().path_join(name)
|
|
var text := FileAccess.get_file_as_string(path)
|
|
if text == "":
|
|
error = "cannot read %s" % path
|
|
return null
|
|
return JSON.parse_string(text)
|
|
|
|
|
|
func read_json(rel: String) -> Variant:
|
|
var path := resolve(rel)
|
|
var text := FileAccess.get_file_as_string(path)
|
|
if text == "":
|
|
error = "cannot read %s" % path
|
|
return null
|
|
var parsed: Variant = JSON.parse_string(text)
|
|
if parsed == null:
|
|
error = "%s is not JSON" % path
|
|
return null
|
|
return parsed
|
|
|
|
|
|
func manifest() -> Dictionary:
|
|
var m: Variant = read_json("manifest.json")
|
|
if m == null:
|
|
return {}
|
|
if m.get("format") != FORMAT_MANIFEST:
|
|
error = "manifest.json is %s, this build reads %s" % [m.get("format"), FORMAT_MANIFEST]
|
|
return {}
|
|
return m
|
|
|
|
|
|
# Screens are addressed by their manifest name, not by a path, so the caller
|
|
# never has to know the archive's subdirectory.
|
|
func screen(name: String) -> Dictionary:
|
|
var m := manifest()
|
|
if m.is_empty():
|
|
return {}
|
|
for entry: Dictionary in m.get("screens", []):
|
|
if entry.get("name") == name:
|
|
var s: Variant = read_json(entry["file"])
|
|
if s == null:
|
|
return {}
|
|
if s.get("format") != FORMAT_SCREEN:
|
|
error = "%s is %s, this build reads %s" % [name, s.get("format"), FORMAT_SCREEN]
|
|
return {}
|
|
return s
|
|
error = "no screen named %s in manifest.json" % name
|
|
return {}
|
|
|
|
|
|
# A transcoded movie, addressed by manifest name. The port never reads WMV --
|
|
# the exporter emits Ogg Theora, which Godot plays natively (MISSION §2, §6).
|
|
func video(name: String) -> Dictionary:
|
|
for entry: Dictionary in manifest().get("videos", []):
|
|
if entry.get("name") == name:
|
|
var path := resolve(String(entry["file"]))
|
|
if not FileAccess.file_exists(path):
|
|
error = "manifest lists %s but %s is not there" % [name, path]
|
|
return {}
|
|
return {"path": path, "command": entry.get("command", "")}
|
|
error = "no video named %s in manifest.json" % name
|
|
return {}
|
|
|
|
|
|
func screen_names() -> PackedStringArray:
|
|
var names := PackedStringArray()
|
|
for entry: Dictionary in manifest().get("screens", []):
|
|
names.append(entry["name"])
|
|
return names
|
|
|
|
|
|
# Textures live outside res://, so they are read as bytes and decoded at
|
|
# runtime rather than imported. Nearest-neighbour: the export is a 1:1 copy of
|
|
# the disc's own texels and several elements are drawn at 200 %, where a
|
|
# bilinear filter would invent detail the disc does not have.
|
|
func texture(rel: String) -> Texture2D:
|
|
var bytes := FileAccess.get_file_as_bytes(resolve(rel))
|
|
if bytes.is_empty():
|
|
error = "cannot read sprite %s" % rel
|
|
return null
|
|
var img := Image.new()
|
|
if img.load_png_from_buffer(bytes) != OK:
|
|
error = "%s is not a PNG" % rel
|
|
return null
|
|
return ImageTexture.create_from_image(img)
|
|
|
|
|
|
## One line naming what a mod replaced, or "" when nothing did.
|
|
##
|
|
## Printed by every run that loads a tree. A modded run that looked identical to
|
|
## an unmodded one in the log would make "disable the mod and see" the only
|
|
## debugging tool a modder has; this makes it the second one.
|
|
func mod_report() -> String:
|
|
if mods == "":
|
|
return ""
|
|
if shadowed.is_empty():
|
|
return "mods: %s is present; each file it replaces is logged as it is read" % mods
|
|
return "mods: %s -- %d file(s) shadowed: %s" % [mods, shadowed.size(), ", ".join(shadowed)]
|