Skip to content
This repository was archived by the owner on Oct 10, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 98 additions & 16 deletions App/AR/ARWorldCommandRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ final class ARWorldCommandRenderer {
private(set) var lastCompanionTransition: CompanionStateTransition?
private var elapsedInCompanionState: TimeInterval = 0

// Animation state for follow only. Loaded on demand, never restarts on repeated follow updates.
private var walkingAnimation: AnimationResource?
private var walkController: AnimationPlaybackController?

init(
registry: AREntityRegistry,
diagnostics: ARDiagnosticRecorder,
Expand All @@ -34,11 +38,25 @@ final class ARWorldCommandRenderer {
self.companionFactory = companionFactory ?? CompanionEntityFactory()
}

func render(_ command: ARWorldCommand, in arView: ARView) -> ARCommandResult {
func render(_ command: ARWorldCommand, in arView: ARView) async -> ARCommandResult {
switch command {
case .spawnCompanion(let presentation):
diagnostics.record(.placementAttempted, detail: "companion")
let entity = companionFactory.makeLira()
guard let entity = await companionFactory.makeLira() else {
diagnostics.record(.placementDeferred, detail: "companion-static-asset-unavailable")
return .deferred("companion-static-asset-unavailable")
}

// Ensure walking animation is available at spawn time for full asset contract.
// Missing walking asset or clip defers safely (no gameplay mutation).
if walkingAnimation == nil {
walkingAnimation = await companionFactory.loadWalkingAnimation()
}
if walkingAnimation == nil {
diagnostics.record(.placementDeferred, detail: "companion-walking-animation-unavailable")
return .deferred("companion-walking-animation-unavailable")
}

let elapsed = CompanionStateReducer.state(for: presentation.behavior) == companionState
? elapsedInCompanionState
: 0
Expand All @@ -47,7 +65,7 @@ final class ARWorldCommandRenderer {
behavior: presentation.behavior,
elapsed: elapsed
)
applyPresentation(for: transition.resolvedState, to: entity)
await applyPresentation(for: transition.resolvedState, to: entity)
let replacing = registry.entity(for: Self.companionID) != nil
guard placementResolver.place(
id: Self.companionID,
Expand All @@ -65,6 +83,12 @@ final class ARWorldCommandRenderer {
} else {
commit(transition, elapsed: elapsed)
}
// Ensure anim state matches resolved (in case spawn starts in follow)
if transition.resolvedState == .follow {
await playWalkingIfNeeded(on: entity)
} else {
stopWalking()
}
return .accepted("companion")

case .updateCompanion(let presentation):
Expand All @@ -81,10 +105,10 @@ final class ARWorldCommandRenderer {
elapsed: elapsed
)
if transition.outcome == .unchanged || transition.outcome == .celebrationInProgress {
applyPresentation(for: transition.resolvedState, to: companion)
await applyPresentation(for: transition.resolvedState, to: companion)
accept(transition, elapsed: elapsed)
} else {
apply(transition, to: companion, elapsed: elapsed)
await apply(transition, to: companion, elapsed: elapsed)
}
return .accepted("companion:\(transition.resolvedState.rawValue)")

Expand Down Expand Up @@ -116,11 +140,16 @@ final class ARWorldCommandRenderer {
}
}

func render(_ commands: [ARWorldCommand], in arView: ARView) -> [ARCommandResult] {
commands.map { render($0, in: arView) }
func render(_ commands: [ARWorldCommand], in arView: ARView) async -> [ARCommandResult] {
var results: [ARCommandResult] = []
for command in commands {
let r = await render(command, in: arView)
results.append(r)
}
return results
}

func setCompanionState(_ state: CompanionPresentationState) -> ARCommandResult {
func setCompanionState(_ state: CompanionPresentationState) async -> ARCommandResult {
guard let anchor = registry.entity(for: Self.companionID),
let companion = anchor.findEntity(named: CompanionEntityFactory.rootName) else {
return .deferred("companion missing")
Expand All @@ -131,11 +160,11 @@ final class ARWorldCommandRenderer {
elapsed: state == companionState ? elapsedInCompanionState : 0
)
if transition.outcome == .unchanged || transition.outcome == .celebrationInProgress {
applyPresentation(for: transition.resolvedState, to: companion)
await applyPresentation(for: transition.resolvedState, to: companion)
accept(transition, elapsed: elapsedInCompanionState)
return .accepted("companion:\(transition.resolvedState.rawValue)")
}
apply(transition, to: companion)
await apply(transition, to: companion)
return .accepted("companion:\(transition.resolvedState.rawValue)")
}

Expand All @@ -146,6 +175,9 @@ final class ARWorldCommandRenderer {
companionState = .idle
elapsedInCompanionState = 0
lastCompanionTransition = nil
stopWalking()
walkingAnimation = nil
walkController = nil
return .cleared
}

Expand All @@ -163,7 +195,8 @@ final class ARWorldCommandRenderer {
requested: companionState,
elapsed: delta
)
apply(transition, to: companion)
// sync apply ok here as no anim
applyPresentationSync(for: transition.resolvedState, to: companion)
return transition
}

Expand All @@ -180,16 +213,17 @@ final class ARWorldCommandRenderer {
return transition
}

apply(transition, to: companion, elapsed: elapsed)
applyPresentationSync(for: transition.resolvedState, to: companion)
commit(transition, elapsed: elapsed)
return transition
}

private func apply(
_ transition: CompanionStateTransition,
to entity: Entity,
elapsed: TimeInterval = 0
) {
applyPresentation(for: transition.resolvedState, to: entity)
) async {
await applyPresentation(for: transition.resolvedState, to: entity)
commit(transition, elapsed: elapsed)
}

Expand Down Expand Up @@ -220,11 +254,13 @@ final class ARWorldCommandRenderer {
: 0
}

private func applyPresentation(for state: CompanionPresentationState, to entity: Entity) {
// Async presentation that also manages animation.
private func applyPresentation(for state: CompanionPresentationState, to entity: Entity) async {
let presentation = presentation(for: state)
entity.position = presentation.position
entity.scale = presentation.scale
entity.orientation = presentation.orientation
// IMPORTANT: Never overwrite the imported mesh's base scale applied once after load in factory.
// State adjustments are position/orientation only (within bounded limits).

entity.findEntity(named: "StatusIndicator")?.isEnabled = presentation.indicatorVisible
entity.findEntity(named: "CoreGlow")?.isEnabled = presentation.coreVisible
Expand All @@ -233,6 +269,52 @@ final class ARWorldCommandRenderer {
SimpleMaterial(color: presentation.indicatorColor, isMetallic: false)
]
}

// Animation: walking clip ONLY for follow; loop while active; stop when leaving.
// Repeated follow updates must not restart the animation.
if state == .follow {
await playWalkingIfNeeded(on: entity)
} else {
stopWalking()
}
}

// Sync variant for time-advance paths (no animation side effects needed for celebrate/idle).
private func applyPresentationSync(for state: CompanionPresentationState, to entity: Entity) {
let presentation = presentation(for: state)
entity.position = presentation.position
entity.orientation = presentation.orientation
// Do not touch scale.

entity.findEntity(named: "StatusIndicator")?.isEnabled = presentation.indicatorVisible
entity.findEntity(named: "CoreGlow")?.isEnabled = presentation.coreVisible
if let indicator = entity.findEntity(named: "StatusIndicator") as? ModelEntity {
indicator.model?.materials = [
SimpleMaterial(color: presentation.indicatorColor, isMetallic: false)
]
}
// No animation management in sync advance (celebrate path).
}

private func playWalkingIfNeeded(on entity: Entity) async {
// Avoid restarting when repeated follow updates arrive.
if let ctrl = walkController, ctrl.isPlaying {
return
}
if walkingAnimation == nil {
walkingAnimation = await companionFactory.loadWalkingAnimation()
}
guard let anim = walkingAnimation else {
diagnostics.record(.error, detail: "companion-animation-unavailable")
return
}
stopWalking()
walkController = entity.playAnimation(anim, transitionDuration: 0.15)
}

private func stopWalking() {
walkController?.stop()
walkController = nil
}

private func presentation(for state: CompanionPresentationState) -> Presentation {
Expand Down
36 changes: 26 additions & 10 deletions App/AR/CanonicalARSessionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,10 @@ final class CanonicalARSessionRuntime {
self.arView = arView
arView.session = sessionCoordinator.session
sceneUpdateSubscription = arView.scene.subscribe(to: SceneEvents.Update.self) { [weak self] event in
self?.drainPendingCommands()
self?.advancePresentation(by: event.deltaTime)
Task { @MainActor [weak self] in
await self?.drainPendingCommands()
Comment on lines +51 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Serialize pending-command drains

While a spawn is awaiting the 50 MB asset load, each scene update starts another unstructured task that enters drainPendingCommands; the receive path can start an additional one as well. Multiple drains can therefore capture the same queue index, render the same command concurrently, and then remove that index after another drain already removed it, either dropping a later command or trapping with an out-of-range removal. Keep a single drain task or otherwise serialize queue ownership across suspension points.

AGENTS.md reference: AGENTS.md:L24-L29

Useful? React with 👍 / 👎.

self?.advancePresentation(by: event.deltaTime)
}
}
sessionCoordinator.onCapabilityStateChange = { [weak self] state in
self?.capabilityState = state
Expand All @@ -69,7 +71,12 @@ final class CanonicalARSessionRuntime {
self.commandHandlerOwner = nil
}
pendingCommands.removeAll(keepingCapacity: true)
report(render(.clearSession, in: arView))
Task { @MainActor [weak self] in
guard let self, let view = self.arView else { return }
let res = await self.render(.clearSession, in: view)
Comment on lines +74 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear the renderer before dropping the AR view

On normal detachment, this task cannot run until the synchronous detach method releases the main actor, but the method sets self.arView = nil before returning. The guard therefore exits without sending .clearSession, leaving the registry, entity anchors, animation controller, and cleanup diagnostic uncleared whenever the runtime survives dismissal; perform the clear before nulling the view or capture the supplied arView directly.

Useful? React with 👍 / 👎.

self.report(res)
self.companionState = .idle
}
sceneUpdateSubscription?.cancel()
sceneUpdateSubscription = nil
sessionStartTask?.cancel()
Expand All @@ -83,15 +90,21 @@ final class CanonicalARSessionRuntime {
guard let arView else { return }
if commands.contains(.clearSession) {
pendingCommands.removeAll(keepingCapacity: true)
report(render(.clearSession, in: arView))
companionState = .idle
Task { @MainActor [weak self] in
guard let self, let view = self.arView else { return }
let res = await self.render(.clearSession, in: view)
self.report(res)
self.companionState = .idle
}
return
}

for command in commands {
enqueue(command)
}
drainPendingCommands()
Task { @MainActor [weak self] in
await self?.drainPendingCommands()
}
}

private func enqueue(_ command: ARWorldCommand) {
Expand Down Expand Up @@ -119,12 +132,12 @@ final class CanonicalARSessionRuntime {
}
}

private func drainPendingCommands() {
private func drainPendingCommands() async {
guard let arView else { return }
var index = pendingCommands.startIndex
while index < pendingCommands.endIndex {
let command = pendingCommands[index]
let result = render(command, in: arView)
let result = await render(command, in: arView)
report(result)
guard case .deferred = result else {
pendingCommands.remove(at: index)
Expand All @@ -147,8 +160,11 @@ final class CanonicalARSessionRuntime {
companionState = transition.resolvedState
}

private func render(_ command: ARWorldCommand, in arView: ARView) -> ARCommandResult {
renderCommandOverride?(command, arView) ?? renderer.render(command, in: arView)
private func render(_ command: ARWorldCommand, in arView: ARView) async -> ARCommandResult {
if let override = renderCommandOverride {
return override(command, arView)
}
return await renderer.render(command, in: arView)
}

private func report(_ result: ARCommandResult) {
Expand Down
Loading