Skip to content

feat!: name-based linkage, field-side declarations, and anchored timers (v4.0.0) - #54

Merged
kamronbatman merged 5 commits into
mainfrom
kb/v4_timer_linkage
Aug 23, 2026
Merged

feat!: name-based linkage, field-side declarations, and anchored timers (v4.0.0)#54
kamronbatman merged 5 commits into
mainfrom
kb/v4_timer_linkage

Conversation

@kamronbatman

@kamronbatman kamronbatman commented Aug 22, 2026

Copy link
Copy Markdown
Member

Summary

The v4 redesign of field↔method linkage and timer serialization, per the agreed design: order-based linkage is removed outright (v3.0.0 is the last release supporting it), linkage is declared on the field only, and timers become intuitive, anchored, and gated.

New surface

One linkage style — everything is declared on the serializable field, naming its companion methods:

[SerializableField(0)]
[SaveFlag(nameof(ShouldSerializeCharges), nameof(ChargesDefaultValue))]   // default method optional
private int _charges;

private bool ShouldSerializeCharges() => _charges != 8;
private int ChargesDefaultValue() => 8;

// Setter hooks are arguments of [SerializableField] itself: they configure the generated
// setter, like the getter/setter/isVirtual arguments.
[SerializableField(1, allowFieldChange: nameof(AllowLevelChange), fieldChanged: nameof(OnLevelChanged))]
private int _level;

private bool AllowLevelChange(ref int value)
{
    value = Math.Clamp(value, 0, 100);   // coerce, or return false to veto
    return true;
}

The generated setter pipeline is equality check → allowFieldChange (coerce/veto) → assign → dirty tracking → fieldChanged. The gate needs no oldValue parameter: it runs before assignment, so the field itself still holds the old value.

The method-side attributes ([SerializableFieldSaveFlag], [SerializableFieldDefault], [SerializableFieldChanged]) are removed entirely. Field-side declaration makes the old failure modes unrepresentable by construction: a default cannot exist without a save flag, a linkage cannot reference a missing field, duplicates are compiler-enforced (AllowMultiple = false), styles cannot conflict, and setter hooks cannot be declared on a [SerializableProperty] — whose user-written setter they could never hook. [SaveFlag] and [DeserializeTimer] remain standalone attributes because they are serialization concerns that legitimately apply to both fields and properties (property support unit-pinned).

Why the gate earns its place: a survey of all 204 [SerializableProperty] sites in the engine found value coercion (clamps, value ?? \"\" normalization) to be the single largest reason for hand-written setters (~50 sites), ahead of post-change side effects (~55, covered by fieldChanged) and vetoes (~5). With both hooks, roughly three quarters of hand-written properties become expressible as plain [SerializableField] declarations; hand-written properties remain for custom getters (~45 sites) and exotic setter semantics.

Timers — one attribute on the field, replacing [TimerDrift] + [DeserializeTimerField]:

[SerializableField(0)]
[DeserializeTimer(nameof(RestartDecayTimer))]      // drift default → anchored time
private Timer _decayTimer;

Drift stores Next as anchored time (downtime no longer consumes the remaining delay, bytes are save-stable); wallClock: true keeps absolute deadlines with the unchanged format. The restart method is invoked only when a timer was running — the TimeSpan.MinValue sentinel is gone.

Compatibility

  • Removed attributes remain as error-[Obsolete] shells whose messages are the migration instructions; the generator carries zero support code for them.
  • The "@TimerDrift" delta-time read fossil survives permanently for old-version migration schemas — pinned by a fixture whose v0 schema replays ReadDeltaTime while the current version reads anchored time, in the same generated file.
  • Save-flag/default/changed conversions are wire-neutral (their Expected snapshots are byte-identical across the change); only drifting timers change bytes and need a class version bump + MigrateFrom. The setter hooks never touch the wire.

Diagnostics

With the mistakes unrepresentable, most of the planned diagnostics deleted themselves: SG3010/SG3014/SG3016/SG3017 are retired (numbers reserved, never reused). What remains is SG3015 — the named method is missing or has the wrong signature — covering save-flag, default, changed, gate, and timer shapes, none of which were validated before v4 (mistakes silently generated broken code) — plus new SG3018: a setter hook (fieldChanged or allowFieldChange) on a field with no generated setter (readonly, or omitted setter), where it could never fire. SG3008 is reworded for [DeserializeTimer].

Also

  • README: the install snippet named a nonexistent package id at a stale version — fixed; v4 patterns and a migration guide documented.
  • Back-to-back benchmarks vs main on identical machine state: warm single-edit 18.7 → 19.8ms, cold ~equal, overlapping error bars — no measurable impact.
  • 101/101 tests green, in both snapshot-pin and regeneration modes; the gate emission is compile-gated in the FieldSideLinkage snapshot and the pipeline order is unit-asserted. Packages bump to 4.0.0.

The engine-side migration (~240 linkage sites; 8 drift-timer classes with version bumps) follows as a separate PR after publish, scripted — and can now also fold eligible [SerializableProperty] sites down to [SerializableField] with hooks.

kamronbatman and others added 5 commits August 22, 2026 16:40
…rs (v4)

Order-based linkage between serializable fields and their companion
methods is removed. Method-side attributes take nameof(_field); every
linkage can alternatively be declared on the field itself with the new
[SaveFlag], [FieldChanged], and [DeserializeTimer] attributes, and both
spellings generate identical code.

Timers consolidate into [DeserializeTimer(nameof(Method))] on the timer
field, replacing [TimerDrift] and [DeserializeTimerField]. Drift is the
default and now stores the timer's next tick as anchored time, so
downtime does not consume the remaining delay; wallClock: true keeps
absolute deadlines with the unchanged wire format. The restart method is
invoked only when a timer was running at save - no more
TimeSpan.MinValue sentinel. The legacy "@TimerDrift" delta-time read
survives permanently for old-version migration schemas, proven by a
fixture whose v0 schema replays ReadDeltaTime while the current version
reads anchored time.

Removed attributes remain as error-[Obsolete] shells whose messages are
the migration instructions. New diagnostics: SG3014 (unknown field
reference), SG3015 (linked method missing or wrong signature - save
flag, default, and changed shapes were previously never validated and
produced broken output on mistakes), SG3016 (both linkage styles on one
field), SG3017 (default value without a save flag, previously silently
ignored). SG3008 now points at the field-side declaration.

Also fixes the README install snippet, which named a nonexistent package
id at a stale version, and documents the v4 patterns and migration.
Back-to-back benchmarks against main on identical machine state show no
measurable impact (warm single-edit 18.7 -> 19.8ms, cold ~equal,
overlapping error bars). Packages bump to 4.0.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SerializableFieldSaveFlag, SerializableFieldDefault, and SerializableFieldChanged
become error-[Obsolete] shells. With linkage declared only on the field, the
mistakes those diagnostics guarded become unrepresentable: a default cannot exist
without a save flag, a linkage cannot reference a missing field, duplicates are
compiler-enforced, and styles cannot conflict. SG3010/SG3014/SG3016/SG3017 are
retired; SG3015 remains the single method-resolution error.

Snapshot Expected files are unchanged, proving the conversion is emission-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The callback is invoked by the generated setter, making it a generated-property
option like the getter/setter/isVirtual arguments - not a serialization concern.
As an argument of [SerializableField] it is unrepresentable on a
[SerializableProperty] (whose user-written setter it could never hook), closing
the silent-ignore case. The standalone [FieldChanged] attribute, introduced
earlier in this unreleased major, is deleted outright.

New SG3018 rejects a fieldChanged callback on a field with no generated setter
(readonly, or omitted setter), where it could never fire.

Snapshot Expected files remain unchanged: emission-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bool Method(ref T value), invoked by the generated setter after the equality
check and before assignment: it may coerce the incoming value through the ref
parameter, and returning false rejects the change. The field still holds the
old value while the gate runs, so no oldValue parameter is needed.

A survey of all 204 [SerializableProperty] sites in the engine found value
coercion (clamps, normalization) to be the single largest reason for
hand-written setters; with this gate plus fieldChanged, roughly three quarters
of them are expressible as plain [SerializableField] declarations. Hand-written
properties remain for custom getters and exotic setter semantics.

SG3018 is generalized to cover any setter hook declared without a generated
setter. The gate emission is pinned in the FieldSideLinkage snapshot (compile-
gated) and unit tests assert the gate -> assign -> fieldChanged pipeline order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kamronbatman
kamronbatman merged commit c6d9852 into main Aug 23, 2026
2 checks passed
@kamronbatman
kamronbatman deleted the kb/v4_timer_linkage branch August 23, 2026 00:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant