Merges the Godot port into the reverse-engineering repository, preserving both
histories -- 1019 commits of corpus plus the port's 31, brought in by subtree
merge and then moved into place so git can follow each file across the rename.
The reason is not tidiness. The two-repo split forced the exporter to depend on
the decoders by pinned revision, and that created a whole class of failure that
now disappears: a sha reachable only from a topic branch, orphaned by a
squash-merge, breaking a fresh checkout silently at build time. It also forced a
live read-only mount of one agent's working tree into another's container, which
is why a contract file could move mid-iteration. With a path dependency, a
decoder change and the exporter change it requires land in the same commit or
not at all.
Canary stays separate: it is a fork tracking upstream.
New structure for the long term:
docs/game/ how the game is NAVIGATED -- menus, modals, prompts, alerts,
and in-game flight. Written so nobody rediscovers it. Mostly
open questions on purpose; the in-game tutorials are the
resource for the flight half.
docs/port/MODDING.md
modding as a constraint on the exporter TODAY, not a later
feature: one logical asset in one file (the disc splits nearly
everything, and resolving that is the exporter's job), names a
person recognises, PNG/OGG/OGV/JSON only, base-and-overrides so
re-exporting is always safe, provenance in every file.
data/base + data/mods
generated tree and drop-in overrides, both gitignored
exchange/ transient inter-agent files, deliberately outside history
docs/agents/ the team protocol
Both the README and the navigation doc lead with the correction that cost the
most: the oracle is the real game under Xenia Canary. Reborn's renderer is a
hypothesis under test, it has been wrong, and treating it as ground truth
propagated into three documents and both agents before a human caught it.
Scripted modding stays possible without being built: no screen name is hardcoded
in GDScript and there is no native code in port/, which is what Godot Mod Loader
needs to be able to substitute behaviour later.
388 lines
16 KiB
GDScript
388 lines
16 KiB
GDScript
# Draws one exported screen, either at a moment on its timeline or at the
|
|
# `rest` pose the export declares.
|
|
#
|
|
# TIMELINE is the real behaviour and the default. A keyframe is the start of a
|
|
# LINEAR ramp toward the next, and the unit of `t` comes from
|
|
# `authored/timing.json` -- it is measured, not on the disc, which is why it is
|
|
# authored and applied in exactly one place.
|
|
#
|
|
# REST reproduces what the export's `rest` field says, which is what
|
|
# `sylpheed-cli screen render` draws. It is kept so `tools/verify-screen` can
|
|
# hold both renderers to the same assumption. The two modes DISAGREE on six
|
|
# elements in this export, and the running game sides with the timeline -- see
|
|
# `docs/DECISIONS.md`.
|
|
#
|
|
# One CanvasItem draws the whole screen in `_draw`, rather than a node per
|
|
# element. The export's `paint_order` is already back-to-front, so honouring it
|
|
# is a loop; z-indexing sixteen nodes to reproduce the same order would be the
|
|
# same information expressed less directly, and would hide a tie behind Godot's
|
|
# own sibling rules.
|
|
class_name ScreenView
|
|
extends Node2D
|
|
|
|
## Skip `kind & 0x4` template instances that duplicate a plain element.
|
|
## docs/FORMAT.md: those are motion-trail ghosts and are not on screen at rest.
|
|
## The narrow form of the rule matters -- 174 elements on the disc carry the bit
|
|
## with no template to duplicate, and a blanket skip would erase them.
|
|
const KIND_TEMPLATE_INSTANCE := 0x4
|
|
|
|
enum Pose { TIMELINE, REST }
|
|
|
|
## Which pose to draw. TIMELINE walks the keyframes at `time_units`; REST draws
|
|
## the export's declared `rest` and is there for renderer-vs-renderer diffing.
|
|
var pose_mode: Pose = Pose.TIMELINE
|
|
|
|
## Position on the timeline, in the disc's own keyframe units. `t` is left raw
|
|
## everywhere; seconds appear only where `units_per_second` is applied.
|
|
var time_units: float = 0.0
|
|
var units_per_second: float = 60.0
|
|
|
|
## Duration of the ramp into the final, untimed keyframe -- the screen playing
|
|
## itself out. Authored (`authored/timing.json`): the disc has no time slot on
|
|
## that keyframe, so this is the one unknown duration per screen.
|
|
var exit_ramp_units: float = 24.0
|
|
|
|
## While true the screen holds at `rest` and never plays its exit. The
|
|
## sequencer clears it to send the screen away.
|
|
var holding: bool = true
|
|
|
|
var tree: ExportTree = null
|
|
var screen: Dictionary = {}
|
|
var textures: Dictionary = {}
|
|
var skipped: Array[String] = []
|
|
var drawn: Array[String] = []
|
|
|
|
## Which button is highlighted, by element id. P1 leaves it empty: initial focus
|
|
## was measured as unstable boot to boot (HANDOFF Q5) and picking one is an
|
|
## authored decision that belongs to P5.
|
|
var focused_id: String = ""
|
|
|
|
|
|
func load_screen(t: ExportTree, name: String) -> bool:
|
|
tree = t
|
|
screen = t.screen(name)
|
|
if screen.is_empty():
|
|
push_error(t.error)
|
|
return false
|
|
var design: Array = screen.get("design", [1280, 720])
|
|
# The export's coordinates are in this space and the viewport matches it, so
|
|
# a mismatch means the export is not what this project was built to draw.
|
|
var viewport := Vector2i(
|
|
ProjectSettings.get_setting("display/window/size/viewport_width"),
|
|
ProjectSettings.get_setting("display/window/size/viewport_height"))
|
|
if Vector2i(int(design[0]), int(design[1])) != viewport:
|
|
push_warning("screen %s is authored at %sx%s, viewport is %s" % [name, design[0], design[1], viewport])
|
|
_load_textures()
|
|
queue_redraw()
|
|
return true
|
|
|
|
|
|
func _load_textures() -> void:
|
|
textures.clear()
|
|
for element: Dictionary in screen.get("elements", []):
|
|
var paths: Array = [element.get("sprite", ""), element.get("focus_sprite", "")]
|
|
# The focus record's own elements carry their own sprites -- the ring is
|
|
# only reachable this way.
|
|
for fe: Dictionary in element.get("focus", {}).get("elements", []):
|
|
paths.append(fe.get("sprite", ""))
|
|
for rel: String in paths:
|
|
if rel != "" and not textures.has(rel):
|
|
var tex := tree.texture(rel)
|
|
if tex == null:
|
|
push_warning(tree.error)
|
|
else:
|
|
textures[rel] = tex
|
|
|
|
|
|
# `tint_rgba` is RGBA and `fade_argb` is ARGB -- different byte orders, on
|
|
# purpose, because the disc spells them differently and a silent swap looks like
|
|
# an art bug rather than a parse bug. They multiply per channel.
|
|
static func modulate_of(pose: Dictionary) -> Color:
|
|
var tint := _rgba(pose.get("tint_rgba", "0xffffffff"))
|
|
var fade := _argb(pose.get("fade_argb", "0xffffffff"))
|
|
return Color(tint.r * fade.r, tint.g * fade.g, tint.b * fade.b, tint.a * fade.a)
|
|
|
|
|
|
static func _rgba(hex: String) -> Color:
|
|
var v := hex.hex_to_int()
|
|
return Color8((v >> 24) & 0xff, (v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff)
|
|
|
|
|
|
static func _argb(hex: String) -> Color:
|
|
var v := hex.hex_to_int()
|
|
return Color8((v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff, (v >> 24) & 0xff)
|
|
|
|
|
|
## The drawn rectangle of an element at a pose.
|
|
##
|
|
## `pos` is the top-left at 1:1 and `pivot` is the anchor scale grows about, so
|
|
## the top-left moves by `-pivot*(s-1)` and the size is the natural size times
|
|
## `s`. At 100 % the pivot cancels, which is why it can be got wrong invisibly.
|
|
static func placement(pose: Dictionary, pivot: Vector2, natural: Vector2) -> Rect2:
|
|
var pos := _vec(pose.get("pos", [0, 0]))
|
|
var s := _vec(pose.get("scale", [100, 100])) / 100.0
|
|
return Rect2(pos - pivot * (s - Vector2.ONE), natural * s)
|
|
|
|
|
|
static func _vec(a: Array) -> Vector2:
|
|
return Vector2(float(a[0]), float(a[1]))
|
|
|
|
|
|
## The pose of one element at `time_units`.
|
|
##
|
|
## A group is `pre-roll -> ramp in -> HOLD -> ramp out -> post-roll`, and a
|
|
## screen that has arrived sits on the **hold**. So the timeline plays in and
|
|
## stops at `rest`, which is the decoders' identification of that hold and
|
|
## carries its own `t`.
|
|
##
|
|
## It is emphatically NOT "play to the last timed keyframe". The exit is not
|
|
## only the final untimed frame -- it can be a long run of TIMED ones. The
|
|
## title's `pteff02` holds at `t=46` with the 25 % dim quad at alpha 0x40 and
|
|
## then ramps to 0x00 by `t=236`; running to the end drops the dim and makes the
|
|
## whole screen ~13/255 too bright. That was measured against a plate-free
|
|
## capture of the running title, and it is what corrected this rule.
|
|
##
|
|
## Before the first keyframe the element holds its first pose -- the pre-roll a
|
|
## staggered menu needs, with the five buttons starting at t=28,30,32,34,36.
|
|
func pose_at(element: Dictionary, t: float) -> Dictionary:
|
|
var frames: Array = element.get("keyframes", [])
|
|
var timed: Array = []
|
|
for k: Dictionary in frames:
|
|
if k.has("t"):
|
|
timed.append(k)
|
|
if timed.is_empty():
|
|
# No timed frame at all: the group is a single static pose.
|
|
return frames[0] if not frames.is_empty() else element.get("rest", {})
|
|
# While holding, stop at the hold: past it the group is ramping out, and a
|
|
# screen that has arrived and is sitting there is not leaving.
|
|
if holding:
|
|
t = minf(t, settle_units(element))
|
|
# The exit. The final keyframe carries no `t` -- the disc has no slot for one
|
|
# -- so it is given a synthetic time `exit_ramp_units` after the last timed
|
|
# frame and then interpolated like any other. That keeps one code path: the
|
|
# difference between arriving and leaving is only how far `t` is allowed to
|
|
# run, not a second kind of animation.
|
|
#
|
|
# The whole group plays out, not just the fade quad: on the main menu
|
|
# pteff00 ramps to opaque black while the labels ramp to transparent and
|
|
# ptframe1/2 hold. Modelling the exit as a black rect over a frozen screen
|
|
# was measured and refuted -- see authored/timing.json.
|
|
var last_frame: Dictionary = frames[frames.size() - 1]
|
|
if not last_frame.has("t"):
|
|
var exit_frame := last_frame.duplicate()
|
|
exit_frame["t"] = float(timed[timed.size() - 1]["t"]) + exit_ramp_units
|
|
timed.append(exit_frame)
|
|
|
|
if t <= float(timed[0]["t"]):
|
|
return timed[0]
|
|
for i in range(timed.size() - 1):
|
|
var a: Dictionary = timed[i]
|
|
var b: Dictionary = timed[i + 1]
|
|
var t0 := float(a["t"])
|
|
var t1 := float(b["t"])
|
|
if t < t1:
|
|
# A keyframe is the start of a ramp toward the next, and the ramp is
|
|
# linear -- measured, `authored/timing.json`.
|
|
return _lerp_pose(a, b, 0.0 if t1 <= t0 else (t - t0) / (t1 - t0))
|
|
return timed[timed.size() - 1]
|
|
|
|
|
|
# Channels are integers on the disc. The running game's own fade lands on
|
|
# `round(255*k/15)`, so rounding -- not truncation -- is what was measured.
|
|
static func _lerp_pose(a: Dictionary, b: Dictionary, f: float) -> Dictionary:
|
|
return {
|
|
"pos": [_ilerp(a["pos"][0], b["pos"][0], f), _ilerp(a["pos"][1], b["pos"][1], f)],
|
|
"scale": [_ilerp(a["scale"][0], b["scale"][0], f), _ilerp(a["scale"][1], b["scale"][1], f)],
|
|
"tint_rgba": _hex_lerp(a["tint_rgba"], b["tint_rgba"], f),
|
|
"fade_argb": _hex_lerp(a["fade_argb"], b["fade_argb"], f),
|
|
"rotation_deg": _ilerp(a.get("rotation_deg", 0), b.get("rotation_deg", 0), f),
|
|
}
|
|
|
|
|
|
static func _ilerp(a: float, b: float, f: float) -> int:
|
|
return int(round(a + (b - a) * f))
|
|
|
|
|
|
# Byte-wise, so it works for both orders without knowing which one it has.
|
|
static func _hex_lerp(a: String, b: String, f: float) -> String:
|
|
var x := a.hex_to_int()
|
|
var y := b.hex_to_int()
|
|
var out := 0
|
|
for shift in [24, 16, 8, 0]:
|
|
out |= (_ilerp((x >> shift) & 0xff, (y >> shift) & 0xff, f) & 0xff) << shift
|
|
return "0x%08x" % out
|
|
|
|
|
|
## Where one element stops, in keyframe units: its hold.
|
|
##
|
|
## `rest.t` when the export gives one. An element whose `rest` carries no time is
|
|
## a single static pose, and there the last timed keyframe is the same answer.
|
|
static func settle_units(element: Dictionary) -> float:
|
|
var rest: Dictionary = element.get("rest", {})
|
|
if rest.has("t"):
|
|
return float(rest["t"])
|
|
var last := 0.0
|
|
for k: Dictionary in element.get("keyframes", []):
|
|
if k.has("t"):
|
|
last = maxf(last, float(k["t"]))
|
|
return last
|
|
|
|
|
|
## The moment the whole screen has arrived: the last element to reach its hold.
|
|
func settle_time() -> float:
|
|
var last := 0.0
|
|
for element: Dictionary in screen.get("elements", []):
|
|
last = maxf(last, settle_units(element))
|
|
return last
|
|
|
|
|
|
## The moment the screen has finished playing itself out, in keyframe units --
|
|
## the last element's final timed keyframe plus the authored exit ramp.
|
|
func exit_time() -> float:
|
|
var last := 0.0
|
|
for element: Dictionary in screen.get("elements", []):
|
|
var frames: Array = element.get("keyframes", [])
|
|
if frames.is_empty():
|
|
continue
|
|
var timed_end := 0.0
|
|
for k: Dictionary in frames:
|
|
if k.has("t"):
|
|
timed_end = maxf(timed_end, float(k["t"]))
|
|
if not frames[frames.size() - 1].has("t"):
|
|
timed_end += exit_ramp_units
|
|
last = maxf(last, timed_end)
|
|
return last
|
|
|
|
|
|
# An element is a ghost only when another element on the same screen carries the
|
|
# same id *without* the template bit -- the template it is a repeat of.
|
|
func _template_instance_ids() -> Dictionary:
|
|
var plain := {}
|
|
for element: Dictionary in screen.get("elements", []):
|
|
if int(String(element.get("kind_raw", "0x0")).hex_to_int()) & KIND_TEMPLATE_INSTANCE == 0:
|
|
plain[element.get("id", "")] = true
|
|
var ghosts := {}
|
|
for element: Dictionary in screen.get("elements", []):
|
|
var kind := int(String(element.get("kind_raw", "0x0")).hex_to_int())
|
|
if kind & KIND_TEMPLATE_INSTANCE != 0 and plain.has(element.get("id", "")):
|
|
ghosts[int(element.get("index", -1))] = true
|
|
return ghosts
|
|
|
|
|
|
## Draw one textured or solid quad, rotated about its pivot.
|
|
##
|
|
## The rotation anchor in design space is `pos + pivot`: `pos` is the top-left
|
|
## at 1:1, so the pivot point sits `pivot` in from it, and scaling about that
|
|
## point is exactly the `pos - pivot*(s-1)` rule the placement already uses.
|
|
##
|
|
## THE GAME DRAWS ROTATION. Confirmed twice by the RE agent, on different
|
|
## screens and different elements -- the title's `ptloop` sweeps declare +30/-45
|
|
## and a GPU capture submits them at +30.26/-45.28, and the main menu's focus
|
|
## ring ramps 0 -> 360 with everything else held constant, caught mid-spin in a
|
|
## capture. The comparison renderer does not draw it yet, so expect a title
|
|
## divergence that means "sylpheed-cli is behind", not "the port is broken".
|
|
##
|
|
## 🟡 The SIGN is an assumption: the decoder documents `+12` as
|
|
## clockwise-positive and Godot's 2D rotation is clockwise-positive in a y-down
|
|
## space, so this passes the value straight through. Not yet checked against a
|
|
## capture at a known angle.
|
|
func _draw_quad(tex: Texture2D, rect: Rect2, colour: Color, pivot: Vector2,
|
|
pos: Vector2, rotation_deg: float) -> void:
|
|
if is_zero_approx(rotation_deg):
|
|
if tex != null:
|
|
draw_texture_rect(tex, rect, false, colour)
|
|
else:
|
|
draw_rect(rect, colour, true)
|
|
return
|
|
var anchor := pos + pivot
|
|
draw_set_transform(anchor, deg_to_rad(rotation_deg), Vector2.ONE)
|
|
var local := Rect2(rect.position - anchor, rect.size)
|
|
if tex != null:
|
|
draw_texture_rect(tex, local, false, colour)
|
|
else:
|
|
draw_rect(local, colour, true)
|
|
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
|
|
|
|
|
|
static func _rot_of(pose: Dictionary) -> float:
|
|
return float(pose.get("rotation_deg", 0))
|
|
|
|
|
|
## Draw a focus record's own elements -- the spinning ring and the bright label.
|
|
##
|
|
## The focused state is NOT a sprite swap. `ptbtn0Nf.rat` declares two elements,
|
|
## and the parent bundle declares NO element for the record at all, so the leaf
|
|
## is the only source of placement for both and there is nothing to inherit.
|
|
## The label is 13 px larger per axis than the base and sits at (-7,-7), which
|
|
## keeps the two concentric; drawing it at the base position pushes it 7 px
|
|
## down-right and off-centre.
|
|
func _draw_focus(element: Dictionary) -> void:
|
|
var focus: Dictionary = element.get("focus", {})
|
|
for fe: Dictionary in focus.get("elements", []):
|
|
var rel: String = fe.get("sprite", "")
|
|
if rel == "":
|
|
continue
|
|
var tex: Texture2D = textures.get(rel)
|
|
if tex == null:
|
|
skipped.append("%s (focus sprite failed to load)" % fe.get("id", ""))
|
|
continue
|
|
# The ring's rest pose. Its spin is real -- rotation_deg ramps 0 -> 360
|
|
# with position, scale and alpha all constant -- but the PERIOD is not
|
|
# established: the ramp's second keyframe is untimed, and what an untimed
|
|
# keyframe means inside a leaf (rather than at screen level, where it is
|
|
# the exit) is untested. So this holds the resting angle and does not
|
|
# invent a spin rate.
|
|
var pose: Dictionary = fe.get("rest", {})
|
|
var pivot := _vec(fe.get("pivot", [0, 0]))
|
|
var pos := _vec(pose.get("pos", [0, 0]))
|
|
_draw_quad(tex, placement(pose, pivot, tex.get_size()), modulate_of(pose),
|
|
pivot, pos, _rot_of(pose))
|
|
drawn.append(fe.get("id", ""))
|
|
|
|
|
|
func _draw() -> void:
|
|
if screen.is_empty():
|
|
return
|
|
var elements: Array = screen.get("elements", [])
|
|
var ghosts := _template_instance_ids()
|
|
skipped.clear()
|
|
drawn.clear()
|
|
for index: int in screen.get("paint_order", []):
|
|
var element: Dictionary = elements[index]
|
|
var id: String = element.get("id", "")
|
|
if ghosts.has(index):
|
|
skipped.append("%s (template instance)" % id)
|
|
continue
|
|
var pose: Dictionary = element.get("rest", {}) if pose_mode == Pose.REST \
|
|
else pose_at(element, time_units)
|
|
var colour := modulate_of(pose)
|
|
if colour.a <= 0.0:
|
|
skipped.append("%s (transparent at rest)" % id)
|
|
continue
|
|
var pivot := _vec(element.get("pivot", [0, 0]))
|
|
var pos := _vec(pose.get("pos", [0, 0]))
|
|
var rot := _rot_of(pose)
|
|
# A focused button draws its own record instead of its base sprite.
|
|
if focused_id == id and element.has("focus"):
|
|
_draw_focus(element)
|
|
continue
|
|
var rel: String = element.get("sprite", "")
|
|
if focused_id == id and element.get("focus_sprite", "") != "":
|
|
rel = element["focus_sprite"]
|
|
if rel != "":
|
|
var tex: Texture2D = textures.get(rel)
|
|
if tex == null:
|
|
skipped.append("%s (sprite failed to load)" % id)
|
|
continue
|
|
_draw_quad(tex, placement(pose, pivot, tex.get_size()), colour, pivot, pos, rot)
|
|
drawn.append(id)
|
|
elif element.get("role", "") == "primitive" and element.has("size"):
|
|
# A primitive has no texture; the quad is its declared size and its
|
|
# colour is the pose's own modulate.
|
|
_draw_quad(null, placement(pose, pivot, _vec(element["size"])), colour, pivot, pos, rot)
|
|
drawn.append(id)
|
|
else:
|
|
# A .t32 element whose sprite the exporter could not produce. Saying
|
|
# so is the point -- a silently missing element looks like art.
|
|
skipped.append("%s (no sprite in the export)" % id)
|