Skip to main content

Remote Control

Change history for jp.lilium.livestudio.remotecontrol.

[0.26.0] - 2026-09-27​

Added

  • A finished restore says so (LiveSceneSerializer.onLiveSceneRestored). Some of what a restore sets in motion cannot finish inside it: a @prefab entry whose asset is not registered yet is queued in PendingPrefabStore rather than dropped, and somebody has to come back for it once it can be resolved. That somebody owns the assets, and resolving one is asynchronous — started from inside the restore it outlives the call and runs during whatever comes next, which in a test suite is the next test. So the restore announces that it is done and the owner decides when to act. Raised after the passes and only for a file that was actually read, so a subscriber is never woken for a scene that did not exist. Cleared on a domain reload, like the other static events here, so a subscriber from a previous play session does not linger when Domain Reload is off.
  • A live scene file can be brought forward before it is read (LiveSceneMigrations). A restore hands each entry in objects[] to the object it names, so an entry naming a member that has since moved or gone finds no owner and is dropped — a scene written by an older build opens looking merely empty, with nothing said. LiveSceneFromJson now runs whatever rewrites the host application registered over the parsed file first, in the one place the whole file is still visible. The hook is here and the rewrites are not: what an old file meant is knowledge of the host's own classes, so this package only knows when to ask. Registration is keyed, so an installer that fires twice ([RuntimeInitializeOnLoadMethod] and [InitializeOnLoadMethod] both do in the editor) replaces its entry rather than stacking a second copy, and a rewrite that throws is not caught — swallowing it would restore the silent half-restore the mechanism exists to prevent. Reading only: nothing writes the old shape again and the file on disk is untouched until the operator saves.
  • LiveObjectSnapshot can take a delta, and the defaults behind it, for one persistence scope. CaptureDelta(handle, scope) / CaptureScopedDefaults(handle, scope) / EnsureScopedDefaults(handle, scope) let an owner of PersistScope.Custom members write its own file the way the live scene writes its own, and put exactly those members back without touching what another writer owns. LiveObjectDefaultRegistry keeps a second baseline for that scope: the existing one is also what a REST delta read reports as changed, so widening it would have changed those answers. AssetStateSnapshot grows the matching CaptureDelta(instance, scope) / RestoreScopedDefaults / EnsureScopedDefaults, plus CaptureFullDelta for a named preset that wants both scopes.
  • Live data: the application's state is taken frame by frame, and a take can be recorded and replayed. FrameGate hooks the PlayerLoop and, at the head of every frame, fills one frame from three lanes. The state lane copies the value of every carried member as bytes — a StateBlock the source generator emits per [LiveClass] type, and DeclaredStateBridge for types declared through a live class asset. The structure lane records which objects exist — id, type, parent, the recipe that built them, and the elements of [LiveKey] collections in their order. The event lane is a sparse log of the writes and calls that came through the gate (REST, deck operations). FrameRecorder writes the frames to a take (*.live.bin) and FrameReplayer supplies them back in place of the live producers: state is written back member by member and only where the bytes moved, the structure is reconciled against the running scene — standing up what the take had and this run lacks through ILiveRecipe / LiveRecipes (PrefabRecipe rebuilds a prefab instance the same way a saved @prefab is rebuilt, and only what the replay made is taken away again) — and events are applied again. A take stays seekable: keyframes restate the whole world at an interval (keyframeInterval), a tail index lets a reader jump, and chunks are compressed (FrameChunkCodec, on by default) by transposing state entries across frames before compressing, which measured about 19% of the raw size. Each state block carries a per-member schema, so a build that has added or removed a member still replays an older take — matching members are copied and missing ones left alone — instead of rejecting the whole type. Strings travel on the state lane as ids into the frame's symbol table by default; textCapacity puts a member in a fixed-width slot instead. Whatever feeds the frame from outside is declared with [assembly: FrameSource("name")]; a component addressed only by its type name (/live/object/Screen) is reached through LiveObjectRoster; FrameGate.HoldSupply stalls a replay while something it needs is still loading; and RealtimeFrameClock numbers frames by the time of day, so frame numbers line up with real time the way the capture side's clock already does. The frame types, the blocks and the recording format live in the new Lilium.RemoteControl.Simulation assembly (see Changed).
  • Which lane carries a member follows from where it is saved (FrameLaneRules). A member the live scene saves is carried by the frame — on the state lane where its type can move as bytes, otherwise as events. A member saved to the project settings (PersistScope.Project), filed by its owner (PersistScope.Custom) or not saved at all stays off it, so what the scene keeps a take carries, and what belongs to the machine (a port, a resolution, a poll interval) is never pressed back by a replay. The one exception is declared explicitly: [LiveField(persistable = false, lane = FrameLane.State)] for a value the scene does not save but a take must carry (a pose, an expression weight, a measured offset). Declaring lane = FrameLane.None on a field or property is an error (LRC013), because a saved-but-uncarried member leaves the scene file describing a world the take disagrees with. To keep a whole type off the frame, declare it on the class: [LiveClass(lane = FrameLane.None)] covers every member and every nested object, down to the first object registered under its own id. [LiveField(onApplied = ...)] runs an effect when a replay changes a state-lane field, the way a property's setter would, and carriedBy marks a property as a view of another member's storage so it reports that member's lane. GET /live/types reports each member's resolved lane, and the generator and the runtime resolve it from the same table.
  • A LiveData Viewer window (Window/Lilium Remote Control/LiveData Viewer) inspects the live data inside the editor. State blocks are decoded member by member — fixed buffers labelled through the new [LiveArray] attribute, and strings shown as text whether they are fixed-width or symbol-table ids — alongside the structure inventory (with each entry's recipe key) and the events of the frame. Empty state types can be folded away. When the running build's schema differs from a recording's, the Viewer and the Sequencer show a banner naming the members found in only one of them (TypeName +onlyInBuild, -onlyInRecording).
  • A replay can loop. FrameReplayer.loop starts the take again when it reaches the end instead of running out, and FrameRecorderController.loop is the switch for it -- settable while a replay is running, not only before one, since this is what an operator reaches for on the last few frames. The rewind is a seek back to the first frame, so it restores the shape of the world from the keyframe there the way any other jump does: a take that spawned something on the way through does not accumulate a second copy of it on the next pass. A recording with no tail index cannot be sought, so it plays once and ends whatever the switch says -- the same limitation as scrubbing, and for the same reason. Nothing loops by default, and a replay that ends still detaches the source and raises onSourceEnded exactly as before.
  • Two analyzers keep the simulation assembly honest: LRC010 (do not touch a view object) and LRC011 (do not read a clock of your own). They ship in the existing generator DLL and run only when compiling Lilium.RemoteControl.Simulation, so nothing else in a project is affected. LRC010 triggers on anything deriving from UnityEngine.Object — a value read from an evaluated scene object cannot be reproduced from a recording — which leaves value types such as Vector3, NativeArray and job structs alone; LRC011 covers UnityEngine.Time, DateTime.Now, Stopwatch and TimeUtility, because time that drives the simulation has to arrive on the frame. Both are warnings, so the one legitimate exception — RealtimeFrameClock, which is the door real time comes through — suppresses LRC011 with a comment saying why. This is deliberately narrower than banning the engine outright: an earlier attempt to declare noEngineReferences on the assembly also took away the job system and the native containers, which is the layer's whole purpose, while still not covering DateTime.
  • A LiveData Sequencer window (Window/Lilium Remote Control/LiveData Sequencer) records a take and plays one back. The counterpart of the LiveData Viewer, and built to look like it: a status bar carrying the position (click to switch between a frame count and a timecode, both counted from the start of the take rather than from the start of the run), a transport, and the takes on disk. Each take is listed with the frames and the running time it actually holds, read out of its tail index — a take that was cut short, or one from a build whose recording format has moved on, says so on its own row instead of being dropped from the list, because a take that is there but unreadable and a take that was never written look the same once it is missing. Selecting one and pressing play replays it into the running application; the slider scrubs it. A take can be removed from its own row, behind a confirmation naming the file — these live outside the Unity project, so the editor’s own trash does not reach them and the delete is final; the take being written cannot be removed at all, and one being replayed stops first, because a handle on a file refuses the delete outright rather than deferring it. The header opens the folder they are written to, creating it first, since it only comes into being once something has been recorded. It drives the registered FrameRecorderController and owns no recorder of its own, so a remote page and this window can be open at once — and a stopped editor, or a run with no recorder registered, disables the controls and says which of the two it is.
  • FrameRecorderController records and replays live data — moved here from jp.lilium.livestudio, and no longer a component. Everything it does is this package's: the gate, the recorder, the replayer, retaining the state and structure systems for the length of a take, and putting the inventory back on a supplied frame. Getting that sequence right is not something each application should have to rediscover. The one thing it does not know is where an application files its work, which is now asked of recordingFolderProvider (defaulting to a folder beside the player's own data), and ExcludeControlObject(id) is how whatever drives it keeps its own buttons out of the takes — without which a replay presses them again, starting a second recording or tearing down the replay that is running it. Breaking for source naming Lilium.LiveStudio.FrameRecorderController; nothing has shipped it, and the exposed class name is still FrameRecorder. It is a plain registered object with a fixed id rather than a MonoBehaviour: as a component it had two addresses — its own id, and a component of the GameObject holding it — and the exclusion that keeps its own settings out of a take covered only the first, so a replay pressed the take number and compression setting it was recorded with back over the operator's. Record() and Replay() refuse outright while the editor is not playing, since the gate is not pumped there and a take would open a file and never receive a frame.
  • Held replay and scrubbing on the controller: replayPaused, replayFrameCount, replayIndex, SeekReplay(index) and recordingPath. replayIndex is a position within the take, not the recorded frame number — see the seek fix below for why those are not the same number offset by a constant.
  • A frame source can be held: FrameReplayer.isPaused. A held replayer keeps supplying the frame it last played — the lanes are pointed at what that frame left, and nothing is advanced or applied again — so the gate goes on taking its frame from the recording. Detaching instead would hand the world back to the live producers and overwrite the frame being looked at, which is the opposite of a pause. Held before the first frame it still plays one, because the player's lanes are empty until then and supplying those blanks the world rather than holding it.
  • Live class assets can be named in Project Settings, so a declaration applies to the whole project instead of one scene. Attribute-less exposure had exactly one entry point: a RemoteControlContainer applied the assets it carried while it was enabled. That is right for declarations that travel with a set bundle — they have to leave when the bundle does — but wrong for declarations that describe the app itself, which then had to be wired into every scene, and which silently stopped being saved in any scene that forgot the reference (the serializer skips a type that is not registered). The new RemoteControlProjectSettings holds a list of assets applied once at load — before the first scene's objects wake up in a player, and at editor load too, since remote control also serves a stopped editor. It resolves the way LiveStudio's settings do: a per-project override registered as a config object, else the same override found in the AssetDatabase, else the default asset shipped in this package's Resources. The Project Settings page (Project/Lilium Remote Control) shows the package default read-through and promotes a per-project override to Assets/Settings/RemoteControlProjectSettings.asset on the first edit, registering it as the config object and adding it to the preloaded assets so a player build carries it — and, through it, the live class assets it names. Editing the list re-applies immediately rather than waiting for the next domain reload. The container list is untouched and still the right place for bundle-carried declarations.
  • The LiveData Sequencer and the LiveData Viewer are translated (English, Japanese, Simplified Chinese), and so is anything else this package's editor windows write. They read their text through the same LocalizationSystem the application and the remote app share, so a window follows the language chosen there rather than holding one of its own — and English is now the source the text is written in, which it was not before. The files are the editor's own and live outside Resources, under Editor/Localization/RemoteControlEditorLocales/, since a player build has no use for strings describing windows that exist only in the editor. A key missing from the active language falls back to English before it falls back to the key: a window nobody has translated yet is still readable, and one full of LDS_ tokens is not. Supporting this, LocalizationSystem gained Format(key, args) (placeholders belong inside the translated text — word order is the first thing a translation changes), TryTranslate(language, key, out text) for callers choosing their own fallback, and generation, which moves whenever the active language or the table behind it does. A window that resolves a label once, when it builds the row it sits in, watches that to learn it has to ask again — including after entering play mode, which empties the table.
  • Double-clicking a LiveClassAsset or UIDefinition asset opens it in its editor window.
  • Timecode.ToSmpteString() formats HH:MM:SS:FF, and FrameRate.framesPerSecondNominal gives the whole-number rate a timecode counts in.
  • The Transform manipulator is part of this package now — POST/DELETE /live/manipulator/open, GET /live/manipulator/image, PUT /live/manipulator/camera (moved from jp.lilium.livestudio, wire unchanged). It only ever touched this package's concepts (live objects, TransformValue, property paths), so the session-holding offscreen camera, the PNG frame with its X-Manipulator-State pose header and the pose route all belong here, and any host application gets gizmo editing without carrying a handler of its own. An object whose edited transform is expressed relative to a dynamic parent (an avatar socket, say) implements the moved IManipulatorTarget to describe its own frame. This is deliberately a route, not an image member: a session is per-dialog server state, the frame must travel with the exact pose that rendered it, and pose writes must not run through the editor-write scope (a viewpoint is not scene state, so it must not dirty the scene or the undo stack).
  • Image members: a live object can serve a picture straight off the generic property surface. Declare a getter-only [LiveProperty, ImagePreview] member of the new LiveImageData type (encoded bytes + MIME type) and no bespoke route is needed: a direct GET /live/object/{id}/{path} answers the raw bytes with the right Content-Type, while every JSON-shaped read — the whole-object serialization, single-property reads, /live/batch — folds the member to that very address without invoking the getter, so listing an object never renders its pictures. The remote app's existing ImagePreview control polls whatever address the value carries, which makes the declaration self-describing and the client generic. The getter runs on the main thread once per picture request and should render, encode and return without caching — the client's poll is the refresh loop; returning an invalid LiveImageData answers 404, the same "fall back to a placeholder" contract as the asset thumbnail route. Image members never report changed (a picture is derived state with no baseline, and comparing one would render a frame per poll) and are never persisted. [ImagePreview] on a string member keeps its older meaning — the value is an address served elsewhere and the getter is called normally. Supported on top-level members only; inside nested composites the value degrades to null, since there is no address to fold to.
  • GET /live/type/{name} and GET /live/enum/{name} — one definition, fetched directly. The singular counterparts of /live/types and /live/enums, standing to them exactly as /live/object/{id} stands to /live/objects: the response is the array element itself, not a one-element collection, and a name nothing answers to is a 404 rather than an empty list. A client that has been told a single type went stale (/live/changes reporting the pseudo id @types) or that meets a type it has never seen can now ask for that one definition instead of refetching the whole table. The plural endpoints and their ?type= filter are unchanged, so nothing has to migrate. A name goes in the path here because a type name is an identifier; asset keys reach the same shape a different way (see the entry above), since those carry slashes and cannot be a single path segment.
  • GET /live/asset/{key}/@image — the picture for an asset, alongside the name and type /live/asset/{key} already returns. A client that can label an asset can now show it, without the host application having to serve an image route of its own. The picture is a pseudo-member of the asset rather than a route of its own, spelled like the @parent pseudo-property on objects, because the key runs to the end of the URL (see the entry above). Where the bytes come from stays with whoever owns the assets: AssetRegistry.SetThumbnailProvider is the injected hook (the same shape as the existing name-fallback / catalog-prewarm / group-expander hooks), and the key is passed through untouched, so an application-specific reference works as well as a registry key. Without a provider — or for an asset that has no picture — the answer is 404, which clients already treat as "fall back to a placeholder". This replaces LiveStudio's /live/avatar/image and its /live/snapshot/image, both of which were an application serving pictures for things that are assets; moving the one endpoint here is a breaking wire change and ships without a compatibility shim.
  • PersistScope.Custom: a member declared with it is written by its owner into its own file, so neither the live scene nor the project settings carry it. The scope filter already routed members to one file or the other; a feature whose state is an asset in its own right (LiveStudio's deck files) needed a third answer — "not here" — rather than a bespoke exclusion at each writer. Such an owner also keeps its own baseline and writes when the user edits, so nothing else has to know when it is out of date. Deserialization ignores the scope, so a file written before a member moved to Custom still restores it. LiveObjectSnapshot.Capture(handle, scope) is the public way to serialize one scope from another assembly.
  • A server toggle on the editor toolbar, left of Live Studio's Remote app button (Material Symbols "devices" icon, grey when stopped and green while listening, port in the tooltip). The server is thread-based, so it serves the remote app with the editor merely open just as well as it does in play mode — this button is what turns that on without hunting for the Remote Control Server window. Starting binds it to the RemoteControlBehaviour hosts in the loaded scenes, so the live objects the scene registers in edit mode are immediately browsable; projects that configure their servers in the window instead are started from there (the settings asset is never created as a side effect of a click). Stopping tears the server down rather than parking it, keeping the toolbar's stopped state identical to "never started". While play mode is running the application owns the server — the app's own routes go with the instance and cannot be re-registered from the toolbar — so the button reports state there instead of toggling, and says so in its tooltip.
  • Attribute-less exposure of arbitrary objects via LiveClassAsset. The asset-based counterpart of [LiveClass]: any scene component (built-in or third-party; assets such as ScriptableObjects are the intended future scope) can now be exposed to the remote app without code changes. Exposure settings live in a LiveClassAsset shared across scenes: per-type member definitions (with label, help text, persistence metadata and a polymorphic [SerializeReference] control choice — SliderControl, HideControl, extensible by deriving LiveBindingControl — the single source of the type-level LiveClass registration, so there is no per-instance member-set ambiguity) plus instance-binding entries that carry only a stable GUID key. The scene side is RemoteControlContainer itself, which implements the standard IExposedPropertyTable (the same mechanism PlayableDirector uses): it maps keys to direct scene-object references — robust against renames and hierarchy moves — and turns each resolved entry into a runtime LiveBinding ILiveObject, so listing, editing and live-scene save/restore all work unchanged. Attribute-less exposure is a base capability of a container rather than a companion component: the object list the bindings join and the enable/disable that applies and drops them are the container's own. The binding key doubles as the LiveObject id, so persisted values stay stable across scenes sharing the asset. Because a set bundle carries its own container, the assets and their scene references travel with it and apply on load / drop on unload — LiveClassAssetSystem.UnregisterTypes undoes a registration when the last container defining a type goes away, leaving nothing behind for the next bundle. RemoteControlBehaviour is now a RemoteControlContainer (its own object list and assets are the base class's) and merges the other containers in the loaded scenes on top of its own. The runtime binding wrappers are deliberately kept out of the serialized _objects list and merged as a source of their own: _objects is [SerializeReference], so wrappers placed there were written into the scene file on every save — under [ExecuteAlways] they exist while merely editing — and came back as empty LiveBinding entries that accumulated one set per save. Classes are picked in the new Window ▸ Lilium Remote Control ▸ Live Class Asset panel: "Add Class" picks a Component/ScriptableObject class through a searchable dropdown (grouped by namespace), or "From Selected" picks straight from the currently selected GameObject's own components — either way it adds an empty type definition to the class list. "Add Member" then opens a checkbox-list companion window over that class's exposable properties/fields/methods, left open so several can be toggled in one sitting instead of one dropdown pick at a time. The header holds the asset and the container; the footer's instance-bindings list is hidden entirely until a container is assigned, since there is nothing to bind into without one. Newly exposed members default their label to the nicified member name (useColorTemperature → "Use Color Temperature").
  • LiveClass.Register(Type, ...) — non-generic attribute-less registration that also accepts LiveFunctionDefine[] for exposing methods. LivePropertyDefine gains define-side metadata overrides (control, label, help, section) that take precedence over member attributes, and LivePropertyType / LiveFunctionType constructors accept the corresponding overrides — the registration path attribute-less callers (bindings) use to supply UI metadata.
  • GET /live/changes reports which live objects changed since a revision, as ids only. LiveChangeLog keeps the latest revision per object id rather than a queue of events, so memory is bounded by the number of distinct objects and a client that has been away for any length of time still gets exactly the set it missed — there is no "cursor fell off the end" case to recover from.
  • GET /api/events?since={lastEventId} hands a client the one-shot notices queued for it — toast notifications and confirmation prompts, the only things left that cannot be recovered by re-reading state. The queue is per client with its own read position, so missing a poll delays a notice instead of dropping it. It is also resolvable as a sub-request of POST /live/batch, which is how the remote app collects it: riding the poll it already sends for displayed values means the inbox costs no extra round trip.
  • /api/status reports an instanceId that changes whenever the server is rebuilt, and polling it marks the calling client present. Without a held-open connection there is nothing to notice a restart that polling happened to poll straight through, and a client would sit on cursors and a type table describing a state that no longer exists.
  • IFrameSchedule and IFrameClockSync — the frame barrier's two halves. A source that implements IFrameSchedule says at which clock frame its next frame is due (PacedFrameSource does); a clock that implements IFrameClockSync can be waited on (RealtimeFrameClock does, sleeping while it has time to spare and spinning the rest). This is where an external sync source such as LTC takes over the pacing: implement the wait on its own tick and install it with FrameGate.SetClock. Both are optional — a clock that cannot be waited on, like FrameCounterClock for an offline redraw, is simply not waited for.

Changed

  • ⚠ [LiveFunction] is off the frame by default: a call is no longer recorded unless it says lane = FrameLane.Event. Breaking for anything relying on calls reaching a take. A member derives its lane from where it is saved -- what the live scene keeps, the frame carries -- but a method has no persistence to derive from, and nothing in a signature says whether it changes the world or the machine. Either default is wrong for some calls, so the one to pick is the one whose mistake costs less. Recording by default cost replays that opened a folder, restored a snapshot and wrote a scene file; not recording costs a moment missing from a take. The first is the machine being acted on by a recording of somewhere else.

    That the old default was wrong was already visible in the fix it kept needing: OperationManager, FrameRecorderPage and ExternalAssetManager were each taken off the frame whole ([LiveClass(lane = None)]) to stop their calls from being recorded.

    A call whose effect lands in a member the frame already carries needs nothing -- the member is what a replay reads, and recording the call as well writes the same change twice. SetActiveSet reaches activeSet, WarpTo reaches the avatar anchor's transform, LiveCamera.Switch reaches priority, AvatarChair.Activate reaches its rest* fields; all of them are on the state lane already. Say lane = FrameLane.Event when the effect leaves no trace in any lane and the call is the only record there is -- AvatarController.ResetPhysics, whose effect is on bodies the simulation deliberately does not hold, and MultiSceneManager.CreateScene / UnloadScene / SetActiveScene, whose scenes is an array of a struct and so reaches neither the block (not unmanaged) nor the structure lane (not a reference type).

    ObjectFactoryBase.DestroyObject now says Event where it used to rely on the default. It reaches what the structure lane cannot -- that lane only takes away what it stood up itself, so deleting something that was in the scene before the take began is carried by this call alone.

    ⚠ Nothing detects a call that should have been kept. A member that declares a lane it cannot be carried on gets a diagnostic; a call that is silently absent from a take is found by replaying one. The declaration is the whole guard -- DestroyObject above was missed when the calls were sorted, and what caught it was a test asserting the delete button stays on the live data.

  • ⚠ EventKind is two values, Set and Call: PropertyWrite is Set, FunctionCall is Call, and StructureChange and RegisteredSource are retired. Breaking for anything naming those members; the fix is mechanical. Only two places ever read the kind -- FrameGate counting repeated writes, and FrameReplayer deciding what a seek folds -- and both asked the same question: can this record be folded to the last one per target, or do its count and order carry the meaning. That question is the whole content of the enum now, and it is deliberately not the HTTP verb and not what the event does: an idempotent call whose target names what it acts on belongs in Set, because replaying it twice changes nothing and folding it is what lets a seek land on it. ⚠ Being addressed by an id is not enough -- the id has to be in the target, since the fold keys on the target alone and reads neither the verb nor the payload. The two retired values asked something this lane never answered -- one what the event did, the other where it came from -- which verbId, sourceId and EventFlags already carry. Nothing ever wrote RegisteredSource.

    Five of the six routes that wrote StructureChange are calls now (scene export and import, orphan removal, manipulator open and close). Being outside FunctionCall had put them in the fold, where removeOrphan -- whose target is a constant path -- applied only its last invocation across a seek, and manipulator open and close, which share a target and differ only by verb, collapsed into one. ⚠ @parent stays a Set: the structure lane records parentId but does not apply it (ApplyFrom only creates, destroys and reconciles elements), so the fold on this event is the only path by which a seek restores a hierarchy -- and since the target is one child's @parent, folding gives exactly "where each child ended up".

    The recording format is unchanged. The kind is still one int at the same offset; two of the four values it could hold are simply no longer written, and the reader maps the retired ones (2 becomes Call, an unknown value becomes Set).

  • ⚠ The take format starts its public life at version 1: FrameRecord.kVersion is 1 (was 8). Live data ships for the first time in this release, so the generations the format went through during development are not carried into the public one. The byte layout is unchanged; only the number resets. A take recorded by a development build is refused when it is opened rather than migrated -- record it again. The Unreal side writes the same layout and resets in step, so a take still opens in either engine.

  • ⚠ Which instances are exposed is said by the scene, not by the live class asset: LiveClassAsset.bindings is gone. An entry in the container's own object list -- a LiveComponent or LiveAsset pointing at the object, carrying the id it is exposed under -- is now the only way to give an object an id of its own. Breaking for any asset carrying instance bindings and for the scene half that fed them: RemoteControlContainer no longer implements IExposedPropertyTable (SetReferenceValue / GetReferenceValue / ClearReferenceValue / ResolveKey are removed), bindingObjects and the LiveBinding wrapper go with it, and so do LiveObjectContainer.ResolveAllReferences / RebindLiveObject and the ResolveReferences(IExposedPropertyTable) overrides -- which had no callers at all, since every reference was resolved eagerly when the container applied its assets and never through the table. To migrate a binding: add an entry to the container's object list, point it at the same object, and give it the binding's key as its id. The id is what live.json addresses, so keeping it keeps the saved values.

    The reason is that a binding kept one fact in two files. The asset held the key and the expected type, the scene held key -> object, and nothing but the key joined them, so editing either side alone broke the pair silently -- while the asset's half was near-redundant, the scene's table having the keys already. The asset had also grown into a project-level declaration (Project Settings names assets that apply to every scene; a package ships one), and on that path there is no container at all, so a binding there could never resolve: a scene-specific instance hanging off a scene-independent declaration. The object list says it once instead, in the same place every other exposed object is already named, with the id sitting next to the reference where a broken pair is visible.

    Nothing about the wire moves. An entry registers its target under the target's own type exactly as a binding did, so @id, @type and the members are what they were, and a live scene saved before this loads after it. The Live Class Asset window loses its Instance Bindings footer and its "Add Binding" button -- declaring the class was always the whole job for the ordinary case, since components of a GameObject in the object list are exposed by the declaration alone.

  • ⚠ Several recipe resolvers can stand at once: LiveRecipes.RegisterResolver is now AddResolver, with RemoveResolver to take one back. Breaking for the one caller of the old name, which is in this package. A resolver answers "what could this key be" for a maker that was not registered by key, and there was room for exactly one — which assumed a single owner of that answer. There is not: a prefab already in memory and a prefab that has to be read out of a bundle first have different owners, and the second could not exist while the first held the slot. The resolvers are asked in the order they were added and the first non-null answer is taken, so each one must return null for a key outside what it owns — a resolver that answers everything is one that silences the rest. PrefabRecipe now holds to that: it answers only for a key whose prefab the PrefabRegistry can find right now, and it re-checks on every lookup instead of trusting its cache, so a maker cached for a prefab that has since been unloaded no longer answers forever with nothing while the resolver that could fetch it again is never reached.

  • ⚠ The prefabs the remote can add to a scene are declared on the live class asset, not on the UI definition. LiveClassAsset.prefabs holds one entry per offered prefab -- the same ILiveObjectFactory the page used to hold, plus the category of the page that offers it -- and StandardObjectFactory.factories is gone. Breaking for any UI definition carrying a factory list: its entries have to be moved to a live class asset, and the stale list is dropped from the asset the next time it is written. The reason is that a prefab list was never a property of a page. It says what the application can stand up, which is the same thing the live class asset already says about types, and it has to hold whether or not a page ever lists it -- a saved @prefab is rebuilt from the PrefabRegistry at load, long before any page is drawn. Applying an asset now registers its prefabs there in the same act that registers its types, so an asset that arrives with a bundle brings what it can instantiate with it and stops offering it again on unload. A page's "+" reads the catalogue (LivePrefabCatalog): the scene page offers every declared prefab, a category page the ones declaring its category. The prefab GUID is resolved by the asset's own OnValidate, so the UI Designer's Reset no longer refreshes prefab keys and UIDefinitionPrefabKeyRefresher is removed.

  • The type table says "persistScope": "custom" for a member the owner files itself. It already said "project" for a member written to the project settings, and left the live scene unsaid as the default; a member saved by its owner (a deck) was indistinguishable from a scene one. A client that sorts objects by where they are saved -- the live scene page showing what the scene file holds, the project page what the machine holds -- needs the third answer, or an owner-saved member passes for a scene one. Nothing else in the response moves: the key appears only on members declared PersistScope.Custom.

  • A recorded take is saved as *.live.bin. .livedata was the odd one out in a project where every other compound suffix says what it is (.set.lsb, .prop.lsb, .deck.json). The format did not change — only the name a new take is written under. Takes recorded as .livedata stay listed and playable in the LiveData Sequencer and on the recorder page, which now enumerate both suffixes.

  • The event lane is called Event, not Input: InputFrame is EventFrame, InputRecord is EventRecord, FrameLane.Input is FrameLane.Event, and eleven more. Breaking for anything naming those types; the fix is mechanical. "Input" had come to mean three different things — a FrameSource (something that supplies the frame), this lane, and LiveStudio's own InputSource for operations — and the first of those feeds the state lane as often as this one, since capture pose and time both arrive from a declared source. Explaining the design required the sentence "an input source puts this on the state lane, not the input lane", which is the tell that a word has stopped working. The second reason is that this lane never behaved like an input stream: a property write carries its own value, so replaying it needs no re-computation. It is a sparse log of things that happened, which is what an event is. FrameSource and FrameGate keep their names — a source of input is still a source of input. InputSymbolTable became FrameSymbolTable on the way past, since both lanes use it (state blocks intern their type names there too). Recordings are unaffected — entry and event kinds are written as numbers, so not one byte of the format moves.

  • The frame types moved to a new assembly, Lilium.RemoteControl.Simulation. Breaking for any assembly that names a frame type: add the reference. The simulation holds the state the live data is defined over, and the view (GameObject, Animator, Transform) presents it; the one direction that must never exist is the simulation reading back out of the view, because a value read from an evaluated Transform is not reproducible from a recording. The boundary drawn here is the dependency direction — the new assembly does not reference Lilium.RemoteControl, so it cannot reach live objects, the registry or the bridges, and there is no way into the object graph from inside it. Engine references are kept: the native containers and the job system live in UnityEngine, and this is the layer meant to run on them. What lives in the new assembly: the frame structures, the state and structure blocks, and the recording format (FrameRecord, FrameRecordReader, FrameRecordWriter). What stays: FrameGate (it hooks the PlayerLoop), the bridges, the recording drivers (FrameRecorder, FrameReplayer, FrameRecordPlayer) and the whole exposure surface. Two rules stay conventions rather than compile errors — do not read the view's output from the simulation, and read time from the live data rather than from UnityEngine.Time or the wall clock.

  • FrameLane is in Lilium.RemoteControl.Core now, not with the frame types. It appears on [LiveField(lane: ...)] and [LiveClass], so leaving it in the new assembly would have forced every assembly that merely declares exposure to reference the simulation. Nothing to change for callers that already reference Core; the type and namespace (Lilium.RemoteControl) are unchanged.

  • ⚠ An assembly that declares lane = FrameLane.State must reference Lilium.RemoteControl.Simulation. The generated state bridge names types that live there. The generator now checks and reports LRC004 naming the type and the fix, and withholds the state block rather than letting the failure surface as CS0246 inside generated code the author never wrote.

  • ⚠ The server lives in its own assembly, Lilium.RemoteControl.Server. The HTTP server, the route handlers, the protocol, notifications and confirmations, the RemoteControlBehaviour host and FrameRecorderController moved out of Lilium.RemoteControl and Lilium.RemoteControl.LiveScene, so the domain — live objects, the registry, serialization — no longer carries the transport. Breaking for any assembly that names one of those types: add Lilium.RemoteControl.Server to its references. The moved files keep their namespaces, so nothing else has to change.

  • The Project Settings page is Project/Lilium Remote Control (was Project/Remote Control), so it reads as this vendor's page rather than one of Unity's own in the settings tree and in search.

  • The Live Class Asset window is built on UI Toolkit, like every other window in the package, and the windows share one stylesheet (Editor/Styles/RemoteControlEditor.uss). A member's Control is a dropdown with its fields nested under it, and a member left on Auto explains the lane it landed on (whether the scene saves it, and whether its type can move as bytes).

  • The UI Designer no longer shows a RemoteApp launch/close button; it did nothing.

  • Service<T>.subjects is now a read-only view (IReadOnlyList<T>), and broadcasting goes through the new Service<T>.ForEach(action). The raw List<T> was public and mutable, so any caller could bypass Register/Unregister, and subjects.ForEach(...) broke with a collection-modified exception the moment a subject unregistered itself from inside the callback. ForEach iterates a pooled snapshot, so subjects may safely register and unregister (including themselves) mid-broadcast; read-only LINQ over subjects is unchanged. Breaking only for code that mutated the list directly or called List.ForEach on it — replace the latter with Service<T>.ForEach.

  • SingletonService<T>.subject is a read-only property now; registration still goes through Register/Unregister. Its debug assert also actually fires on double-registration — it used to assert obj != null, which is never false in practice, while the message talked about an already-registered subject. Overwrite-on-register behaviour in release is unchanged.

  • SelectableService<T>'s id→subjects dictionary is private; use Register/Unregister/Select (unchanged).

  • Resetting a property is now POST /live/object/{id}/{path}/@reset. Breaking, no compatibility shim. The bare reset was ambiguous with the array-append route on a member that happens to be called reset — POST /live/object/1/reset could mean either, and only the order of the route table decided it, which meant such a member could never be appended to. @ cannot start a real member name, so the pseudo-member spelling removes the ambiguity instead of ranking it, matching @parent on objects and @image on assets. The batch endpoint takes the same spelling.

  • An asset is now named by a path, not a query parameter: GET /live/asset/{key} replaces GET /live/asset?guid=. This is a breaking wire change and ships without a compatibility shim. Objects have always been GET /live/object/{id}, and the asset endpoints were the last ones that named their subject a different way. The key takes the whole tail of the URL exactly as a property path does in /live/object/{id}/{path} — its inner slashes are kept, so a file reference (file:C:/props/chair.lsb) and an engine asset path (/Game/Props/Chair.Chair, which puts two slashes in the URL) both survive the round trip. Because the key runs to the end, a sub-resource can only be told apart by a name no key ends with, hence the @image pseudo-member, spelled like the @parent pseudo-property on objects; image on its own would be ambiguous with a key ending in a folder of that name. Clients must percent-encode # and ? in a key (a raw # is a fragment delimiter and never reaches the server) as well as a literal %, and must leave / alone; a key containing .. is still rewritten by URL normalization before it arrives. Two consequences inside the package: the asset routes match and parse the raw request path rather than Url.AbsolutePath, which has already dropped an escaped # and collapsed doubled slashes, and the image handler must be registered before the resolver because the latter claims the whole /live/asset/ prefix.

  • Every route this package serves moved out of /api/* and /ui/* into /live/*. RemoteControl is a library, and its handlers share one server and one route table with the host application's own — so each generic name it claimed under /api/ was a name the application could never use again, and the ones it had claimed (status, events, language, commands, asset) are exactly the names an application reaches for first. The collision was already demonstrable in-tree: two servers in one Unity process had independently picked /api/commands. /api/ is now left entirely to the host. /api/status → /live/status, /api/events → /live/events, /api/confirm → /live/confirm, /api/performance → /live/performance, /api/language → /live/language, /api/commands/quit → /live/commands/quit, /api/commands/reset → /live/commands/reset, /api/asset → /live/asset, and the Web UI's /ui/sidemenu / /ui/info → /live/ui/sidemenu / /live/ui/info. The live-object surface (/live/objects, /live/object/…, /live/types, /live/batch, …) is unchanged. /live/ directly is reserved for the package; an application that wants routes there should nest them one level (/live/app/*) so a future package route cannot collide. This is a breaking wire change and ships without a compatibility shim — clients move with it.

  • The live scene's routes are grouped under /live/scene/*. export, import and orphans sat directly on /live/, where each read as a verb about nothing in particular — "export" says neither what is exported nor that the two file endpoints and the orphan list are one feature served by one handler (LiveSceneIoHandler). The live scene is one subject among several the package serves, so it gets a segment of its own like every other: /live/export → /live/scene/export, /live/import → /live/scene/import, /live/orphans → /live/scene/orphans, /live/orphans/remove → /live/scene/orphans/remove. Request and response bodies are untouched, and the scene file format is unaffected — only the paths move. Breaking, with no compatibility shim.

  • Server creation moved out of the config asset: RemoteControlServerConfig.CreateServer is gone and RemoteControlServerManager.GetOrCreateServer builds, wires and registers the instance itself. The config is pure data now, which also removes the duplicated "already exists on this port" check the two paths each carried.

  • Starting a server no longer clears the presence list and broadcasts "Remote Control Server started". The broadcast could not reach anyone (a fresh server has no inboxes yet), and the clear touched only one of the two places that track clients; presence self-heals through the status-poll timeout instead.

  • Removed unused connection plumbing: HttpServerCore.IsPortAvailable, RemoteControlServerManager.GetServer/GetAllPorts, RemoteControlServerCore.SendToClient, EventQueue.SendToClientsAsync/ClearEvents/GetStats/GetActiveClientIds (and the TargetedMessage/DirectMessage/EventQueueStats DTOs), and the RestApiConnectionManager statistics surface (ProcessClientMessage, GetStats, per-client message/response-time tracking, GetClient/GetActiveClients/GetAllClients/GetActiveClientIds). None of it had a caller; the client record is now just identity plus last-activity.

  • Renamed the whole "Exposed" vocabulary to "Live". The system stopped being merely "exposed to the remote" long ago — the same property set is what LiveScene persists, operations bind to and the remote app edits — so the name now matches LiveStudio / LiveScene / live.json. Attributes: [ExposedClass] → [LiveClass], [ExposedProperty] → [LiveProperty], [ExposedField] → [LiveField], [ExposedFunction] → [LiveFunction], [ExposedEnum] → [LiveEnum], [ExposedExternalEnum] → [LiveExternalEnum], [ExposedKey] → [LiveKey], [ExposedDefault] → [LiveDefault]. [ExposedHelp] is simplified to [Help] (decoration attributes carry no prefix), and [FormerlyExposedAs] becomes [FormerlyNamedAs]. Every Exposed* type and file is Live* accordingly (ExposedObjectHandle → LiveObjectHandle, ExposedObjectRegistry → LiveObjectRegistry, ExposedPropertySerializer → LivePropertySerializer, the "ExposedObjects Viewer" window → "LiveObjects Viewer", …), the SendMessage hooks OnExposedChanged / OnExposedReset are now OnLiveChanged / OnLiveReset, and the source generator matches the Live attribute names. REST routes move from /exposed/* to /live/* (objects, object, types, enums, changes, batch, function, export, import, orphans) — a deliberate wire break; remote clients update in lockstep. Saved data keeps loading: the built-in proxy typeNames (GameObject, Asset, Component, …) never contained "Exposed", and Unity-serialized renames carry [MovedFrom].

  • Removed [LiveValue]. The displayed-property sync already polls every visible property, so a dedicated per-property polling control had nothing left to do; read-only members now render as plain read-only values and update through the ordinary sync.

  • The package's asset-creation entries share a single Live Studio ▸ Remote Control submenu. Assets ▸ Create listed them under two roots — Remote Control (UI Definition, Server Config) and Lilium ▸ Remote Control (Live Binding Preset) — and neither sat with the Live Studio packages they ship alongside. Menu paths only affect where the command appears; existing assets and their serialized types are untouched.

  • Property changes are no longer pushed over SSE. LivePropertyBroadcast records the changed object's id in LiveChangeLog instead of serializing the value and fanning it out to every connected client. A broadcast went to all clients regardless of what each was looking at, and the value was serialized once per change even when nobody displayed it; now a client polls the change feed and refetches only the objects it holds. The entry points and their call sites are unchanged.

  • types_update and ui_update events are replaced by the pseudo ids @types and @ui in the same change feed, so cache invalidation travels the same path as everything else.

  • Nothing is pushed to a remote app any more. The SSE stream (/api/stream) and its long-poll machinery are gone; notifications and confirmation prompts travel through the per-client inbox above. EventQueue keeps its queues and read positions — only the held-open connection that drained them was replaced.

  • "Is a remote app connected" (which decides whether a confirmation prompt has anywhere to show) is now "did one poll us in the last 5 seconds" — five times the connection-check interval. A shorter window would drop a prompt on the floor whenever a phone answered slowly; the event inbox keeps its own, much longer retention, since that answers a different question (is there anything left to hand over when a client comes back).

  • GetClientId prefers the client-declared X-Client-ID header over the TCP endpoint. Inbox delivery has to survive across separate requests, and the source port does not.

  • Removed /api/heartbeat. It was never registered on any route, and the client-liveness it was meant to carry now comes from the status poll.

  • Nothing is broadcast on a timer any longer. The last such producer, the expression-weight update, sent every active weight to every connected client ten times a second no matter what page each had open; the remote app now polls AvatarController.expressions through the ordinary displayed-property sync, so the weights cost nothing while the expression cards are closed and reach only the client looking at them.

  • FrameRecorderController is renamed LiveDataRecorder, and its exposed class name changes from FrameRecorder to LiveDataRecorder. Users see recordings called "live data" everywhere, as in the LiveData Viewer and LiveData Sequencer windows, while Frame* stays the name of the machinery underneath (FrameRecorder, FrameReplayer). The old exposed name was also the name of the lower-level recorder class this one drives. This is a breaking change for source. Existing data carries over:

    • A project's FrameRecorder.settings.json still applies, because entries are matched by the recorder's fixed id. [FormerlyNamedAs("FrameRecorder")] keeps the old type name from being reported as a mismatch. The next save writes LiveDataRecorder.settings.json and removes the old file.
    • [MovedFrom] lets a serialized reference to the old C# type load.
  • FrameRecorderController exposes only its two project settings, keyframeInterval and compress. The take number, the replay file, the loop switch and the readouts (isRecording, recordedFrames, recordedMegabytes, isReplaying, replayPaused, replayFrameCount, replayIndex) are no longer exposed. They remain public C# members for the LiveData Sequencer window and for whatever drives the controller. A remote page drives the application's own transport instead (RecordingManager in jp.lilium.livestudio), so the exposed copies were only a second, unused set of the same controls on the project's settings page. replayFrame is removed because nothing read it.

Fixed

  • The Live Data Viewer allocated at every frame head for as long as it was open. Its tap built the id resolver it hands to the capture as a lambda closing over a local, so a closure and a delegate were created at each frame — two allocations a frame, sixty times a second, for the whole time the window is up. That is the hitch this window exists to hunt, produced by the act of watching it. The table is a field now and the delegate captures nothing, so one is built for the session. It also made the zero-allocation tests fail whenever the window happened to be open, since the gate notifies its observers inside the frame they measure.

  • A live scene file given as a relative path — including the default file of an app that never records one, such as Fusion — now resolves inside the open project instead of Application.persistentDataPath. LiveSceneSaveSystem kept the startup state and the project settings in the project folder but resolved the scene file itself against the player's data folder. An app that does not switch scenes records no scene in startup.json, so it read and auto-saved one machine-wide file whatever project it was pointed at: a run given -project for isolation still loaded, and on quit could overwrite, the operator's own state. Relative paths now resolve against the same state directory, which is still persistentDataPath when no project is set. A scene file saved there by an earlier build is not carried over.

  • An edit made in the first moment after the app answers on its port is kept. The host opened the server in OnEnable but applied the startup live scene in Start, and applying one ends by adopting everything now live as the "nothing is unsaved" baseline (what it just applied came from disk, not from the operator). A REST write landing in that window -- about a second wide, which is long enough for a remote app or a test to connect and write -- was swallowed by that re-baseline: the value stayed, but the app stopped counting it as an edit, so quitting dropped it with no prompt; where the file carried the same member, the restore overwrote the value outright. The server now opens after the restore has been applied (_LoadInitialDataThenStartServer), including the deferred case where the load switches the base scene and the deserialize waits for the reload. Answering on the port is therefore the app's promise that an edit made now will be kept. Health answers about a second later than before; the editor's own server is started elsewhere and is unaffected.

  • A live scene saved with an additively-loaded world up no longer records that world as its base scene. ResolveBaseSceneName preferred the active scene, on the assumption that a world loaded on top of the app always comes from an AssetBundle and so has no build index. A world shipped inside the build breaks that assumption: the host makes its scene active for lighting, the scene has a build index like any other, and the save recorded it as baseSceneName. Loading that file then booted the world as the whole app and loaded it a second time on top of itself, leaving two copies of the world, no app scene, and nothing for the rest of the file to bind to. It now takes the first loaded build scene — the scene the app booted into — since everything loaded after it was loaded on top of it and is by definition not the base. (jp.lilium.livestudio's built-in sets are the case that hit this; a file already saved this way keeps the wrong name and needs its baseSceneName corrected or the state re-saved.)

  • Project settings written while a class was a component still reach it once it stops being one. Such entries are keyed by @parent — the id of the GameObject the component sat on — so a class that moves off its GameObject to become a plain registered object left every one of them unresolvable, and the operator's saved settings quietly reverted to defaults on the next launch. ProjectSettingsSerializer now reads the shape rather than the name: an entry carrying @parent whose @type resolves to a class that is not a Component is applied to that class's registered instance instead, with @parent and @componentIndex dropped as belonging to a shape the entry no longer has. Only where the class has exactly one instance, which is what a class in this position is — the settings were one machine's, and a class that came off its GameObject came off it to be the single holder of them; anything else is skipped with a warning rather than guessed at. Stated as a rule about shape so the next class to make the same move needs no further change here. (jp.lilium.livestudio's Screen is the first to make it.)

  • A take recorded on a machine that was struggling played back fast: recorded at twenty frames a second and watched at sixty, it ran at three times the speed it happened. A replay walked one record per frame head, which is a count and not a duration. Nothing was wrong with the recording — the frame numbers say exactly how far apart the records are, and a run below rate skips numbers, so a take with a record every third number is three times as long as its record count suggests — but nothing read them. PacedFrameSource now wraps the replayer and decides at each head how many of its records are due, measuring the time with the frame it is filling: the gate stamps a frame with the live clock's number and rate before it asks a source to fill it, so the pacer reads no clock of its own. None due holds the frame it is showing, several due puts them all back in order at that head. FrameRecorderController wraps every replay it starts; a caller that wants the old walk uses FrameReplayer directly, which is what an offline redraw wants and is now the difference between the two modes.

    ⚠ What is not fixed by this is the tick. FrameGate.deltaTime under a paced replay is this machine's step, not the take's, so anything integrating outside what the take carries (a spring, a particle, a camera rig) advances by real time and therefore differently on a machine that renders at another rate. The unpaced walk is where the take's own step is handed out, and driveEngineTimeOnSuppliedFrames still does not put the recorded frame number on the supplied frame — so that mode drives the engine's clock with this machine's interval too. Both belong to the same unfinished job and are worth doing together.

    Four things a paced replay has to get right, each of which was found by asking rather than by playing a take: a supply held for loading is not time the take sprints through afterwards (FrameGate.heldFrameCount is what tells a wait from a machine falling behind); a head that has fallen far behind puts back a bounded number of records and drops the rest rather than making a sprint of it; a catch-up stops at a record that changed the shape of the world, because the inventory is only acted on at the frame head that has not run yet; and a record that stops the replay playing it back (a take carries the requests made during it) ends the head there, since everything after it would be read out of a recording that has just been closed. FrameGate now detaches a source only if it is still the one attached, so a take that ends by starting another does not have the new one cleared before its first frame.

  • The LiveData Sequencer's timecode counted records rather than frames, so a take recorded below rate read a third of the time it took. recordedFrames and replayIndex are positions in a recording; a timecode is a distance on a clock, and the two are the same number only while the machine holds rate. The window now reads FrameRecorderController.recordedFrameSpan while recording and replayFrameSpan while replaying, both distances in frame numbers, and counts the replay in the take's own rate (replayFrameRate) rather than in this machine's. FrameRecorder exposes firstFrameNumber and lastFrameNumber for the first of those.

  • A type declared by an asset stayed registered for the rest of the session once a scene holding an instance of it was torn down without a host. The registration is deliberately kept alive for as long as something exposes an instance -- the handles hold the live class -- but a wrapper whose object had since been destroyed still counted, and nothing ever detaches one of those. So the last container to leave could not take the declaration with it: the type went on appearing in /live/types and in component lists, and re-applying the asset skipped the rebuild because its signature had not changed. Destroyed targets are now dropped from that count as it is taken.

  • A "+" pressed while recording stood up two objects on playback, not one. ObjectFactoryBase.CreateObject is now declared lane = FrameLane.None and leaves no record; DestroyObject stays on the event lane. What a "+" produces is already carried by the inventory — the spawned object names the prefab it came from, and the structure lane rebuilds it under the id it was recorded with — so recording the press as well made both lanes stand one up: forward play got the inventory's copy plus a second one under a fresh id that no recorded value addresses, while a seek got only the inventory's, because a seek drops function calls. Re-pressing could not be made right either, since the call takes an index into a list that grows and shrinks with what is loaded, not an id that means the same thing in the next run. Deleting is the opposite case and is kept: it is addressed by an id, applying it twice is harmless (the second finds nothing), and it reaches what the inventory cannot — the structure lane only takes away what it stood up itself, so a delete of something that was in the scene before the take began is carried by the event alone. ⚠ That delete still does not survive a seek, for the same reason any function call does not.

  • Seeking to a frame finds it by number instead of counting on from the first, so a scrub lands where it was asked to. FrameRecordReader.TrySeekFrame treated the index as contiguous — frame n at position n - first — which is only true of a run that never misses a beat. Frame numbers come from the clock, so a run below rate skips them: a 2628 frame take recorded at sixty hertz was measured spanning 2805 numbers with 58 gaps, and seeking into it landed up to tens of frames from where it was asked for. Silently, because the frame it landed on is a perfectly good frame. The lookup is now a binary search over the index, reading each probe's boundary out of the file — about a dozen small reads, against the walk over every frame the index exists to avoid. FrameRecordReader and FrameRecordPlayer gained FrameNumberAt(index) and IndexOfFrame(frameNumber) so a caller can convert between a position in a recording and a frame number rather than assuming; firstFrameNumber is exposed too, but only as what the recording starts at. Nothing about the format moved, so existing recordings read as they did — they just seek correctly now.

  • A type registered after startup no longer shows as "no type info" in a remote app that was already connected. Registering a type later — lazily, or through a live class asset that arrives with a scene or a bundle — grew the type table without telling anyone, so a client kept its old /live/types for the rest of the session. Every registration and unregistration now reports @types on the change feed. The startup scan could also miss types in assemblies that were not loaded yet, when a static constructor happened to run it from a worker thread; the rescan after assemblies load now combines TypeCache with the loaded assemblies, on the main thread.

  • LiveClassAssetSystem.UnregisterTypes undoes the types the asset actually registered instead of re-reading its current definitions, so a definition removed from an asset before it unloads is still unregistered with it.

  • Translations are registered in edit mode too ([InitializeOnLoadMethod] alongside RuntimeInitializeOnLoadMethod). The server answers a remote app from a stopped editor, and until play started every label and help text reached it as its raw key.

  • An edit made from the editor to a member the live object stores itself can be undone, is saved and repaints. Undo, dirtying and repainting only happened when the live object was itself a UnityEngine.Object, so outside play mode a change to a plain C# wrapper's member — or to one serialized by the container that owns the wrapper — was lost on the next save. LiveEditorWriteScope now resolves the object that actually serializes the value (the wrapped component, or the owning container) and forces a player-loop update so [ExecuteAlways] components redraw straight away.

  • The LiveObjects Viewer no longer slows the editor down: dirty marks are opt-in and its property panel refreshes at a throttled rate. SelectPropertyDrawer caches derived-type lookups across drawers.

  • A reset reports failure when there is nothing to restore, and a writable member that is not saved resets to the value it started with. RevertValue / ResetValue always reported success, and a non-persisted member behind a property had no baseline anywhere, so resetting it did nothing. Such members now have their starting value captured alongside the persistence baselines (and not recaptured when an asset re-baselines after loading). @reset also respects the member's lane, so resetting a member that is off the event lane no longer records an event for a replay to press again.

  • A Timecode built from a frame count no longer loses a frame near whole seconds (at 60 fps, frames 60 and 61 both came out as frame 0); it is now derived from the whole count instead of going through seconds as a double.

  • Stop → Start now actually restarts a server. HttpServerCore.StopServer used to tear down every registered route, so an instance restarted from the Remote Control Server window came back up answering nothing but 404. Handlers now survive a stop and are torn down only when the server instance itself is disposed, making stop and start symmetric.

  • HttpServerCore.Dispose flagged the instance disposed before calling StopServer — which refuses to run on a disposed instance — so disposing a running server without an explicit stop beforehand leaked the listener. It now stops (and releases the listener) first.

  • An unexpected exception in the listener loop was swallowed by Task.Run, leaving the server silently dead while IsRunning still reported true. The loop now logs the exception and exits, so the running state and OnServerStopped tell the truth.

  • In a player build nothing ever tore the servers down: every teardown hook (beforeAssemblyReload, play-mode exit, editor quit) was an editor event, so the background cleanup tasks each server spawns only died with the process. Application.quitting now removes all servers in players too.

  • The Remote Control Server window and its editor auto-start no longer throw when the settings asset lists a config whose asset does not exist in the current project (a shared settings asset can reference configs from packages the project does not include).

  • GET /live/types could 500 with "Collection was modified" when serializing a type lazily registered another [LiveClass] type (derived-type resolution inside ToJObject mutates the registry). The handler now snapshots the type list before serializing.

  • Replaying a take no longer fast-forwards everything that runs on Time.deltaTime. Springs, particles and animators ran at the render rate divided by sixty — two and a half times fast at 144 Hz, far more in an editor with vSync off. Two things combined: Time.captureDeltaTime was set on the engine frames that pumped the gate and then stayed set on every frame in between, so one step of the take was spent once per rendered frame; and the step itself was measured on the live clock, because FrameReplayer never stamped the take's own frame number on the frames it supplied. It now does — a supplied frame carries the take's number and rate, so FrameGate.deltaTime is the take's step (several records at one head make one wide step, and a head that holds where it is covers none), and the timecode read off a supplied frame is the take's. While a replay drives the engine's clock, the gate also waits at the head until the next record is due, so the engine renders one frame per step of the take: the take is the primary of a render cluster and the replay applies its time. A head where the take does not move (a pause, a load being waited for) gives the engine back to real time, a wait that gives up without a frame does the same, and a tick is never wider than a quarter of a second. If something else already holds Time.captureDeltaTime (a recorder), the replay leaves it alone and warns once.

[0.25.3] - 2026-07-22​

Added

  • RemoteConfirmSystem raises a confirmation on every surface at once — the OS dialog on the machine running the app and a modal in each connected remote app — and resolves it from whichever answers first, dismissing the rest. An operator working from a phone could not answer a prompt that only existed on the PC, and someone at the PC could not answer one that only existed in the remote app. Prompts travel as localization keys with the app's translation attached as a fallback, so each remote app renders them in ITS language. Answers come back over POST /api/confirm (a default route, since the framework itself raises prompts).
  • NativeConfirmDialog shows the OS dialog on its own thread and can be closed from another one. The blocking ConfirmDialog cannot back a mirrored prompt: while it is up the app cannot serve the REST call carrying the remote answer, let alone act on it. ConfirmDialog stays for terminal, single-surface paths.
  • RemoteControlBehaviour.TrySaveOrPrompt saves to the current path or opens the platform's "Save As" picker, picking the Editor or player dialog itself.
  • QuitApiHandler.onQuitRequesting vetoes a quit asked for over REST. A player already has Application.wantsToQuit for this, but in the Editor a remote quit forces play mode off and the resulting ExitingPlayMode cannot be aborted — so without a veto here the Editor tore down before an asynchronous unsaved-changes prompt could be answered, and the prompt never reached the remote apps at all. Pressing Stop in the Editor still uses the local, un-mirrored dialog: nothing can hold that path open.
  • LiveSceneSaveSystem.ResetAllToDefault reverts every contained object to its captured defaults regardless of dirty state. RevertAllToDefault only touches objects reporting dirty properties, and a load re-baselines everything as clean, so right after one it reverts nothing — a caller that has to discard the current state (switching to another project) needs the unconditional form.
  • LoadCurrentData / LoadCurrentDataFrom take forceBaseSceneReload, which reloads the base scene even when the file already targets the active one. Without it a load into the same base scene deserializes on top of the live state, so every value the (delta) file omits keeps whatever was there before.

Changed

  • The unsaved-changes prompt shown when quitting now goes through RemoteConfirmSystem, so it appears in the remote apps as well as on the machine. It no longer needs the deferring coroutine either: the prompt never blocks wantsToQuit, which simply declines the quit and lets the answer start a new one.

Fixed

  • The package compiles on Unity 6.5 (6000.5), where Object.GetInstanceID / Resources.InstanceIDToObject — and the EntityId⇔int implicit conversions that had kept them working since Unity 6.3 — were promoted to Obsolete errors. Every call site goes through ExposedObjectUtility, which is now the single place holding the version split, and the ID is carried as long so it still fits once EntityId outgrows 32 bits. The @instanceID field and the numeric-id fallback in ExposedObjectHandler are unchanged for callers; the values remain session-scoped and must not be persisted.

[0.25.2] - 2026-07-21​

Added

  • ExposedObjectHandle.MarkClean / ExposedObjectDefaultRegistry.MarkClean adopt an object's current state as the user-change baseline, so nothing on it counts as an unsaved edit until the next write. Only the user-change baseline moves; the serialization baseline keeps the captured defaults, so a delta save that follows still writes the full diff. Call it wherever state arrives from disk.

Fixed

  • Unsaved-change detection no longer reports a scene as dirty on every launch, which blocked quitting a Studio that had opened an existing project. The check serialized the whole scene and string-compared it against the file, so it could not tell "the user edited something" apart from "this build serializes the scene differently than the build that wrote the file" — any project whose live.json came from an older version failed it forever. It now asks the per-object dirty tracking, re-baselined wherever state arrives from disk (scene load, save, deferred pending entries, asset load tails).
  • IsDirty and GetDirtyProperties compare in persistence shape (forPersistence: true), matching CaptureDefaults and the per-property baseline. Dirty must mean "a save would write something different", so read-only and non-persistable members — exactly what async loading churns after a restore: the crawl-built ExternalAssetManager.assets catalog, rebuilt StageManager.sets, TransformRef.availableOwnerNames — no longer count.

[0.25.0] - 2026-07-17​

Added

  • Invoking exposed functions with arguments and through property paths: ExposedPropertySerializer.BuildInvokeArguments builds a typed positional argument array from a JSON array (shared by the REST invoke path and callers that replay a stored call), and ExposedObjectHandle.ResolveFunction resolves a function reached through a property path (a nested function on a member), mirroring how the REST layer resolves nested members.
  • ExposedProperty.TryGetValue<T> / TrySetValue<T>: read and write value-type members without boxing, through typed delegates (Func<object,T> / Action<object,T>) emitted by the Source Generator.
  • ShowIf / HideIf accept multiple conditions and can be placed on methods. Conditions serialize as a visibilityConditions array and are AND-evaluated on the client, so a member — or a function button — can be gated by several conditions at once. A single condition still emits the legacy visibility field, keeping the type output byte-compatible.
  • ImagePreviewAttribute: a read-only control that polls a server-relative image URL and renders it (backs the Capture camera preview).
  • ExposedObjectRegistry.keyedCollectionVersion and NotifyKeyedCollectionChanged(), so consumers can invalidate cached property resolutions when a keyed collection's elements are rebuilt and stop writes landing on stale keys.

Changed

  • Cut GC from the exposed property pipeline. bool / enum getters return canonical boxed instances via BoxedValues; oldValue reads and property-change events are skipped when nothing is subscribed; FindProperty / GetProperty are span-based (no name.ToString()); array-element descriptors are cached (ExposedPropertyType.GetArrayElement) and element lookup reads the collection once, building only the matched element; PropertyPath slash conversion and AppendIndex are allocation-free; ExposedUnityObject.components replaces its LINQ filter with a two-pass hand roll. REST responses and scene.json are unchanged.

[0.24.3] - 2026-07-09​

Added

  • AssetSelector control: asset-reference fields serialize as asset GUIDs (AssetRegistry, baked in OnValidate and registered at runtime). GET /api/asset?guid= resolves a GUID to the asset's name and type so clients can label the choices. Sub-assets use a guid:localId composite key, and an optional refProperty carries scheme-prefixed external keys (e.g. file:<path>#<sub>) whose target may not be loaded yet; AssetRegistry gains name-fallback and display-name resolution so those unloaded keys can still be labelled.
  • GET /exposed/orphans and POST /exposed/orphans/remove: root references a saved scene carries but whose object is absent this session are surfaced as removable "missing" items (path-carrying child overrides are excluded — they still bind once their asset loads asynchronously). Removing one drops every queued entry rooted at that id, so the next scene save stops re-emitting it.
  • RemoteErrorNotifier forwards Unity errors (Error / Exception / Assert) to every connected remote app as an error notification. The broadcast is marshalled to the main thread (the server registry is mutated only there), identical messages are de-duplicated and bursts are capped so a per-frame exception cannot flood the app. Enabled by default (RemoteErrorNotifier.enabled).

Changed

  • The live-scene serializer no longer logs an ambiguous-restore warning for persisted-file entries. They carry no @name and cannot be disambiguated by type, so they are left unresolved for the remote app to present and delete instead of emitting a warning the user cannot act on.

[0.24.2] - 2026-07-05​

Changed

  • Breaking: GET /exposed/objects and GET /exposed/object/{id} now return objects at depth 1 by default. A nested inline (unregistered) composite child is emitted as a truncation stub { "@type": ..., "@truncated": true } instead of being fully expanded, keeping the payload small and scalable as the object graph grows. Arrays do not consume depth, so element count and per-element type stay visible, and registered children keep their @ref form. Pass ?nested (or ?nested=true) to restore the previous unbounded expansion. Property reads (GET /exposed/object/{id}/{path}), SSE broadcasts, PUT responses and persistence (scene / project / preset) are unchanged — always fully expanded, so saved files stay byte-identical. Clients that walked nested values from the object list must either pass ?nested or lazily fetch each truncated child via the property GET.

Added

  • POST /exposed/batch endpoint applies multiple object / property / function operations in a single request with per-item continue-on-error (each item's status and body are echoed back in order). The exposed REST API is now documented in Documentation~/openapi.yml.
  • Per-member persist scope: [ExposedField] / [ExposedProperty] gain a persistScope (Scene default / Project), so the serializer can split live-scene state from per-class project settings.
  • Exposed key-path addressing: a property path can target an array element by a stable [ExposedKey] value (e.g. expressions[Joy].weight), which backs the generic SetPropertyAction "bind to key" flow.
  • [Collapsed] attribute: a hint that makes the remote app render an array or nested struct collapsed by default (the expand toggle remains). Emitted as a standalone collapsed flag, independent of the property controller.
  • elementTypeOptions is emitted for polymorphic array properties so the remote app can offer "add an element of type …".
  • A dedicated GET /api/performance endpoint reports the app's current FPS and process memory (sampled from the player loop via PerformanceMonitor; memory is read through the native ProcessMemoryReader on Windows), so the remote app can poll it only while an overlay is showing instead of piggybacking on other requests.

Fixed

  • ExposedPropertyUtility.CreateDefaultElement falls back to the first concrete [ExposedClass] subtype for abstract / interface element types, so adding an element to a polymorphic [SerializeReference] array no longer throws MissingMethodException.
  • Constructing an ExposedObjectHandle no longer bakes an already-applied override value into its dirty-detection baseline. The constructor now uses EnsureDefaultsCaptured (capture only when unset, preserve an existing baseline) instead of an unconditional SetDefault, so an override captured by live-scene restore before the handle was registered is no longer overwritten with the current (applied) value and then lost on save.

[0.23.6] - 2026-06-23​

Changed

  • [StringSelector] can now annotate method parameters in addition to properties and fields. An ExposedFunction argument so marked is rendered as a dropdown whose choices come from the owning object's sourcePropertyName property, letting a function take a constrained string argument (e.g. selecting a named stage mark).

[0.23.4] - 2026-06-22​

Fixed

  • A ?type=X query no longer drops a first-class [ExposedClass] component (e.g. AvatarController exposed as "Avatar") when its GameObject is also surfaced through a generic wrapper handle. The component and the wrapper are distinct exposed identities; the previous GameObject-identity de-duplication made RemoteApp pages report "No avatars available" once the avatar was exposed as an ExposedGameObjectWithTransform. Genuine same-target duplicates are still collapsed via FindByTarget.
  • Live scene serialization now skips only a real-but-destroyed (fake-null) Unity reference during a base-scene reload, while still serializing a pure proxy that legitimately never had a backing reference.

[0.23.2] - 2026-06-21​

Fixed

  • FrameRate.AsFrameNumber now computes in double instead of casting through float. Past 2^24 (~3.2 days at 60fps) float lost integer precision, collapsing consecutive frame numbers onto the same value and stuttering the Studio playback buffer routing; double is exact for frame integers up to 2^53.

[0.23.0] - 2026-06-21​

Added

  • [ExposedExternalEnum(typeof(T))] assembly attribute registers external or built-in enums (e.g. HumanBodyBones) as remote-app dropdowns without modifying the type; supports excludeNames and the registration survives Reset().
  • ExposedObjectSnapshot can now capture an exposed object's default state and compute the delta against it (CaptureDefaults / CaptureDelta), the basis for prop presets.
  • The remote app is now notified when a live scene is saved.

Fixed

  • Saved live-scene entries that reference an unresolved @type now deserialize away cleanly instead of leaving a null hole.
  • Live-scene restore now reapplies scene visibility and active state correctly on startup.

[0.22.1] - 2026-06-14​

Fixed

  • Live scene saves now record only a build-settings scene as baseSceneName. Saving while an additive .scene.lsb world (buildIndex -1) was the active scene previously wrote the world's name, so loading that file in a build without the world dirtied the active scene and blocked graceful quit with an unsaved-changes dialog. The name is now resolved to the active scene only when it is a build scene, otherwise the first loaded build scene (worlds are calibrated on top of it), applied to both the save and the HasUnsavedChanges baseline.

[0.21.2] - 2026-06-11​

Fixed

  • ExposedObjectUtility.InstanceIDToObject now calls the public Resources.InstanceIDToObject instead of reflecting the internal UnityEngine.Object.FindObjectFromInstanceID, which broke on Unity 6.1 (6000.3) where its argument changed from int to an EntityId struct. The public API keeps an int argument across Unity 2021.3–6000.3.

[0.21.0] - 2026-06-08​

Changed

  • Member access is now backed by source-generated accessors, removing the runtime dependency on Unity.Properties and reducing reflection overhead.
  • ExposedObject is now a readonly struct and was renamed to ExposedObjectHandle, eliminating transient view allocations during property access.

Fixed

  • Static [ExposedClass] types no longer disappear from the exposed set when their static constructor runs before all game assemblies are loaded; attributes are re-scanned after assembly load.
  • Inherited [ExposedClass] members are now registered in declaration order.

[0.20.12] - 2026-06-04​

Added

  • switchSceneOnLoad option for LiveScene loading, controlling whether loading a scene also switches to it.

[0.20.11] - 2026-06-03​

Added

  • Log the live scene file path when a scene is saved.

[0.20.10] - 2026-06-02​

  • No functional changes (version synchronized with the monorepo release).

[0.20.9] - 2026-06-02​

Added

  • allowExternalConnections option to bind the server to all network interfaces.

Changed

  • Handlers are now registered per-instance with their routes declared in the constructor.