The Decoder's closing point -- the inference is cheap and the measurement looks expensive right up until someone does it -- is actionable, so the probe I reverted is now permanent. The exporter records each transcode's duration and frame rate in the manifest (probed from the file it wrote, not the source), and every video run prints what it showed against what the media holds. An instrument that has to be added before the question can be asked will not be there the next time somebody reasons instead. Then the instrument corrected me twice more. It is an UPPER BOUND, not a count. It counts engine frames, and the engine renders the UI at its own rate: on a quiet box ADV drew 6480 frames across a 4123-frame video, 44 fps against the media's 30. Above that crossover it constrains nothing, and '157% presented' is the counter used outside its range. The report now says so instead of printing a percentage. So 'the player skips, heavily' is not supported. At 8.3 engine fps under contention S00A could not have shown more than 28% -- a valid bound under contention and nothing more. Quiet, the bound is 88-90%, permitting anything from no drops to a tenth. And the 720p-versus-432p contrast is refuted -- the finding I sent them twice. I reported ADV +6.7% against S00A -0.5% and built 'heavy decode falls behind, light keeps up' on it. Quiet, both run +6.7...+6.9%. The -0.5% was a contended run in which the player dropped frames to hold schedule. I was measuring which run happened to share the box and reading it as a property of the resolution. What survives is sturdier than either: playback runs +6.7%...+6.9% long on this container, five runs, both videos, quiet, resolution-independent. Three corrections in three iterations, all mine, all the same shape: argued from an absence; measured and over-read; then found the measurement was taken under a confound I introduced myself by running the suite alongside it. Their rule needs a companion -- ask what the quantity can be skipped by, and ask what else was running. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
193 lines
7.2 KiB
GDScript
193 lines
7.2 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 {}
|
|
# `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)]
|