Files
Sylpheed/port/scripts/export_tree.gd
Sylpheed port agent 13bc0b02f7 port: make the frame count permanent, then correct what I read from it twice
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
2026-08-30 23:26:18 +00:00

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)]