# 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 `/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 `/../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) ## 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: 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 {} # `duration_s` and `fps` come with it so a run can report what it # PRESENTED, not just how long it took. Godot's player drops frames # to hold its schedule and drops most of them on this hardware, and # elapsed seconds stay plausible while that happens. return { "path": path, "command": entry.get("command", ""), "duration_s": float(entry.get("duration_s", 0.0)), "fps": float(entry.get("fps", 0.0)), } 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)]