Capture current mission, AI, map and administration development
Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
User-approved almost-perfect spectator camera baseline, saved before synchronization changes on 2026-10-09. Includes Enter/chat input fix from the previous turn. No publication or server change. sha256.json records exact file contents. Restore only files changed by the smoothing patch from this baseline; do not reset the whole working tree.
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
ActionManager : "{795184CF9AD764DB}Configs/System/chimeraInputCommon.conf" {
|
||||
Contexts {
|
||||
ActionContext ERR_ObserverContext : "{C538807EEE48BED8}Configs/System/ActionContext/ManualCameraContext.conf" {
|
||||
Actions {
|
||||
Action ERR_ObserverPrevious {
|
||||
InputSource InputSourceValue "{A7B19F416E541001}" {
|
||||
Input "keyboard:KC_LEFT"
|
||||
}
|
||||
}
|
||||
Action ERR_ObserverNext {
|
||||
InputSource InputSourceValue "{A7B19F416E541002}" {
|
||||
Input "keyboard:KC_RIGHT"
|
||||
}
|
||||
}
|
||||
Action ERR_ObserverView {
|
||||
InputSource InputSourceSum "{A7B19F416E541003}" {
|
||||
Sources {
|
||||
InputSourceValue "{A7B19F416E541004}" {
|
||||
Input "keyboard:KC_RETURN"
|
||||
}
|
||||
InputSourceValue "{A7B19F416E541005}" {
|
||||
Input "keyboard:KC_NUMPADENTER"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
MetaFileClass {
|
||||
Name "{A7B19F416E541000}Configs/ERR/ObserverInput.conf"
|
||||
Configurations {
|
||||
CONFResourceClass PC { }
|
||||
CONFResourceClass HEADLESS : PC { }
|
||||
CONFResourceClass XBOX_ONE : PC { }
|
||||
CONFResourceClass XBOX_SERIES : PC { }
|
||||
CONFResourceClass PS4 : PC { }
|
||||
CONFResourceClass PS5 : PC { }
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
ERR_ObserverCamera {
|
||||
ID "634904BCEDA1A285"
|
||||
m_bInputEnabled 1
|
||||
m_sActionContext "ERR_ObserverContext"
|
||||
m_bStartAtEntity 1
|
||||
m_aComponents {
|
||||
ERR_FollowSurvivorCameraComponent "{965C2E8B2339B926}" {}
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
MetaFileClass {
|
||||
Name "{EB1210AAE1BB8F54}Prefabs/ERR/SpectatorCamera.et"
|
||||
Configurations {
|
||||
EntityTemplateResourceClass PC {
|
||||
}
|
||||
EntityTemplateResourceClass HEADLESS : PC {
|
||||
}
|
||||
}
|
||||
}
|
||||
+463
@@ -0,0 +1,463 @@
|
||||
enum ERR_ObserverReason
|
||||
{
|
||||
NONE,
|
||||
SPENT_LIFE,
|
||||
LATE_EXTRACTION,
|
||||
SPAWN_UNAVAILABLE,
|
||||
UNCONSCIOUS
|
||||
}
|
||||
|
||||
class ERR_ObserverRules
|
||||
{
|
||||
static int Cycle(bool enabled, bool left, bool right, bool previousLeft, bool previousRight)
|
||||
{
|
||||
if (!enabled || left == right) return 0;
|
||||
if (left && !previousLeft) return -1;
|
||||
if (right && !previousRight) return 1;
|
||||
return 0;
|
||||
}
|
||||
static bool Toggle(bool enabled, bool pressed, bool previous)
|
||||
{
|
||||
return enabled && pressed && !previous;
|
||||
}
|
||||
static bool Casualty(bool active, bool currentBody, bool alive, bool conscious)
|
||||
{
|
||||
return active && currentBody && alive && !conscious;
|
||||
}
|
||||
static bool View(int reason, bool active, bool alive, bool conscious)
|
||||
{
|
||||
if (!active || reason == ERR_ObserverReason.NONE) return false;
|
||||
if (reason == ERR_ObserverReason.UNCONSCIOUS) return alive && !conscious;
|
||||
return !alive;
|
||||
}
|
||||
}
|
||||
|
||||
// Presentation only. Existing server admission and observer selection own state.
|
||||
class ERR_ObserverGuidance
|
||||
{
|
||||
static string Message(int reason, bool survivorAvailable)
|
||||
{
|
||||
if (reason == ERR_ObserverReason.NONE) return "";
|
||||
string message = "You are spectating until the next round.";
|
||||
if (reason == ERR_ObserverReason.SPENT_LIFE)
|
||||
message = "Your life for this round has been used. You can play again next round.";
|
||||
else if (reason == ERR_ObserverReason.LATE_EXTRACTION)
|
||||
message = "Extraction has started. New prisoners can join next round.";
|
||||
else if (reason == ERR_ObserverReason.SPAWN_UNAVAILABLE)
|
||||
message = "Your prisoner could not be spawned. You are spectating until the next round.";
|
||||
else if (reason == ERR_ObserverReason.UNCONSCIOUS)
|
||||
{
|
||||
message = "You are unconscious. Your body remains where you fell and teammates can still treat you. Your view returns automatically when you wake up.";
|
||||
if (!survivorAvailable) return message + " No conscious teammate is currently available; the camera stays near your body.";
|
||||
}
|
||||
if (!survivorAvailable)
|
||||
return message + " No teammate or body is currently available to follow.";
|
||||
return message + " Press Left / Right arrow to change teammate or body (or /esc spectate next / /esc spectate previous). Press Enter to switch between third-person and the living teammate's camera. Player view waits in third-person if no live camera stream is available. Use camera look controls to orbit and zoom controls to change distance in third-person.";
|
||||
}
|
||||
static bool Refresh(bool shown, int previousReason, int reason, bool previousAvailable, bool survivorAvailable)
|
||||
{
|
||||
return reason != ERR_ObserverReason.NONE && (!shown || previousReason != reason || previousAvailable != survivorAvailable);
|
||||
}
|
||||
}
|
||||
|
||||
class ERR_ObserverCameraClass : SCR_ManualCameraClass {}
|
||||
|
||||
// Gate before SCR_ManualCamera activates its context: disabling only the follow
|
||||
// component is too late, since the camera context can already consume chat keys.
|
||||
class ERR_ObserverCamera : SCR_ManualCamera
|
||||
{
|
||||
override protected bool IsDisabledByMenu()
|
||||
{
|
||||
if (super.IsDisabledByMenu()) return true;
|
||||
if (GetGame().GetInputManager().IsContextActive("ChatContext")) return true;
|
||||
Widget focused = GetGame().GetWorkspace().GetFocusedWidget();
|
||||
while (focused)
|
||||
{
|
||||
if (EditBoxWidget.Cast(focused)) return true;
|
||||
focused = focused.GetParent();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// A local follow camera. It never possesses its target or offers free flight.
|
||||
[BaseContainerProps(), SCR_BaseManualCameraComponentTitle()]
|
||||
class ERR_FollowSurvivorCameraComponent : SCR_BaseManualCameraComponent
|
||||
{
|
||||
protected IEntity m_Target;
|
||||
protected IEntity m_Vehicle;
|
||||
protected float m_Yaw;
|
||||
protected float m_Pitch = 20;
|
||||
protected float m_Distance = 5;
|
||||
protected bool m_LeftDown, m_RightDown, m_EnterDown;
|
||||
protected int m_InputResumeFrames;
|
||||
protected bool m_PlayerView;
|
||||
protected ref ERR_ObserverHeadVisibility m_ViewHead = new ERR_ObserverHeadVisibility();
|
||||
protected float m_OrbitFov, m_OrbitNear;
|
||||
protected ref ERR_ObserverScopeDisplay m_ScopeDisplay = new ERR_ObserverScopeDisplay();
|
||||
protected bool m_InputContextActive;
|
||||
bool InputContextActive() { return m_InputContextActive; }
|
||||
bool IsPlayerView() { return m_PlayerView; }
|
||||
// Process one edge per press; menus still consume the state so holding a key
|
||||
// while closing a menu cannot unexpectedly change the followed teammate.
|
||||
void Input(bool enabled, bool left, bool right, bool enter)
|
||||
{
|
||||
// An inactive context reports zero even while a physical key is held. Let
|
||||
// the reactivated context settle, then require release before accepting keys.
|
||||
if (!enabled) m_InputResumeFrames = 2;
|
||||
else if (m_InputResumeFrames > 0)
|
||||
{
|
||||
enabled = false;
|
||||
if (m_InputResumeFrames > 1 || (!left && !right && !enter)) m_InputResumeFrames--;
|
||||
}
|
||||
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController());
|
||||
int cycle = ERR_ObserverRules.Cycle(enabled, left, right, m_LeftDown, m_RightDown);
|
||||
if (controller && cycle != 0) controller.ERR_CycleObserver(cycle);
|
||||
if (ERR_ObserverRules.Toggle(enabled, enter, m_EnterDown)) m_PlayerView = !m_PlayerView;
|
||||
m_LeftDown = left; m_RightDown = right; m_EnterDown = enter;
|
||||
}
|
||||
override bool EOnCameraInit()
|
||||
{
|
||||
m_OrbitFov = GetCameraEntity().GetVerticalFOV();
|
||||
m_OrbitNear = GetCameraEntity().GetNearPlane();
|
||||
return true;
|
||||
}
|
||||
override void EOnCameraExit() { m_ScopeDisplay.Close(); m_ViewHead.Restore(); }
|
||||
override void EOnCameraFrame(SCR_ManualCameraParam param)
|
||||
{
|
||||
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController());
|
||||
if (!controller || GetGame().GetCameraManager().CurrentCamera() != GetCameraEntity()) { m_ScopeDisplay.Close(); m_ViewHead.Restore(); return; }
|
||||
m_InputContextActive = GetInputManager().IsContextActive("ERR_ObserverContext");
|
||||
Input(param.isManualInputEnabled, GetInputManager().GetActionValue("ERR_ObserverPrevious") > 0.5, GetInputManager().GetActionValue("ERR_ObserverNext") > 0.5, GetInputManager().GetActionValue("ERR_ObserverView") > 0.5);
|
||||
IEntity target = controller.ERR_ObservedEntity();
|
||||
float fov, nearPlane;
|
||||
if (m_PlayerView && controller.ERR_ReadPlayerView(target, param.timeSlice, param.transform, fov, nearPlane))
|
||||
{
|
||||
param.fov = fov; GetCameraEntity().SetNearPlane(nearPlane);
|
||||
m_ViewHead.Update(target, param.transform[3]);
|
||||
RplId scopeWeapon; vector scopeData;
|
||||
controller.ERR_ReadScope(scopeWeapon, scopeData);
|
||||
SCR_2DOpticsComponent optic = ERR_ObserverScope.Resolve(target, scopeWeapon, scopeData);
|
||||
IEntity weapon;
|
||||
if (optic)
|
||||
{
|
||||
RplComponent weaponRpl = RplComponent.Cast(Replication.FindItem(scopeWeapon));
|
||||
if (weaponRpl) weapon = weaponRpl.GetEntity();
|
||||
}
|
||||
if (!param.isManualInputEnabled || !optic || !weapon || !m_ScopeDisplay.RenderScope(optic, weapon, fov, scopeData)) m_ScopeDisplay.Close();
|
||||
param.isDirty = true;
|
||||
return;
|
||||
}
|
||||
// No estimated head camera: corpses, streaming gaps and expired samples use
|
||||
// the stable follow view until an authenticated live camera stream arrives.
|
||||
m_ScopeDisplay.Close();
|
||||
m_ViewHead.Restore();
|
||||
param.fov = m_OrbitFov; GetCameraEntity().SetNearPlane(m_OrbitNear);
|
||||
vector aim = controller.ERR_ObserverAnchor() + "0 1.5 0";
|
||||
vector position = aim + "0 4 -6";
|
||||
IEntity exclude = target;
|
||||
if (target)
|
||||
{
|
||||
IEntity vehicle = CompartmentAccessComponent.GetVehicleIn(target);
|
||||
vector transform[4];
|
||||
if (vehicle)
|
||||
{
|
||||
vehicle.GetWorldTransform(transform);
|
||||
vector lower, upper;
|
||||
vehicle.GetWorldBounds(lower, upper);
|
||||
aim = (lower + upper) * 0.5;
|
||||
exclude = vehicle;
|
||||
}
|
||||
else
|
||||
{
|
||||
target.GetWorldTransform(transform);
|
||||
aim = transform[3] + "0 1.5 0";
|
||||
if (!ERR_Actors.Alive(target)) aim = transform[3] + "0 0.4 0";
|
||||
}
|
||||
if (target != m_Target || vehicle != m_Vehicle)
|
||||
{
|
||||
vector angles = transform[2].VectorToAngles();
|
||||
m_Yaw = angles[0] + 180; m_Pitch = 20; m_Distance = 5;
|
||||
if (vehicle) m_Distance = 12;
|
||||
m_Target = target; m_Vehicle = vehicle;
|
||||
}
|
||||
if ((!m_PlayerView || !ERR_Actors.Alive(target)) && param.isManualInputEnabled)
|
||||
{
|
||||
m_Yaw += GetInputManager().GetActionValue("ManualCameraRotateYaw") * 90 * param.timeSlice;
|
||||
m_Pitch = Math.Clamp(m_Pitch - GetInputManager().GetActionValue("ManualCameraRotatePitch") * 90 * param.timeSlice, 5, 75);
|
||||
float scale = Math.Clamp(1 + GetInputManager().GetActionValue("ManualCameraZoom") * 12 * param.timeSlice, 0.5, 1.5);
|
||||
float minimum = 2, maximum = 12;
|
||||
if (vehicle) { minimum = 6; maximum = 25; }
|
||||
m_Distance = Math.Clamp(m_Distance * scale, minimum, maximum);
|
||||
}
|
||||
float yaw = m_Yaw * Math.DEG2RAD, pitch = m_Pitch * Math.DEG2RAD;
|
||||
vector offset = Vector(Math.Sin(yaw) * Math.Cos(pitch), Math.Sin(pitch), Math.Cos(yaw) * Math.Cos(pitch));
|
||||
position = aim + offset * m_Distance;
|
||||
}
|
||||
TraceParam trace = new TraceParam();
|
||||
trace.Start = aim;
|
||||
trace.End = position;
|
||||
trace.Flags = TraceFlags.WORLD | TraceFlags.ENTS;
|
||||
trace.LayerMask = TRACE_LAYER_CAMERA;
|
||||
trace.Exclude = exclude;
|
||||
float fraction = GetCameraEntity().GetWorld().TraceMove(trace, null);
|
||||
if (fraction < 1) position = vector.Lerp(aim, position, Math.Max(0.05, fraction - 0.05));
|
||||
Math3D.DirectionAndUpMatrix(vector.Direction(position, aim), "0 1 0", param.transform);
|
||||
param.transform[3] = position;
|
||||
param.isDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
modded class SCR_PlayerController
|
||||
{
|
||||
[RplProp()]
|
||||
protected ERR_ObserverReason m_ERRObserverReason;
|
||||
[RplProp()]
|
||||
protected RplId m_ERRObservedId = RplId.Invalid();
|
||||
[RplProp()]
|
||||
protected vector m_ERRObserverAnchor;
|
||||
protected IEntity m_ERRServerObserved;
|
||||
protected IEntity m_ERRServerObservedVehicle;
|
||||
protected SCR_ManualCamera m_ERRObserverCamera;
|
||||
protected bool m_ERRObserverMonitor;
|
||||
protected bool m_ERRObserverReady;
|
||||
protected ERR_ObserverReason m_ERRShownObserverReason;
|
||||
protected bool m_ERRShownObserverAvailable;
|
||||
protected string m_ERRShownRecoveryStatus;
|
||||
protected float m_ERRLastCycle = -1000;
|
||||
protected IEntity m_ERRRecoveryBody;
|
||||
protected float m_ERRRecoveryDeadline = -1;
|
||||
|
||||
ERR_ObserverReason ERR_ObserverState() { return m_ERRObserverReason; }
|
||||
vector ERR_ObserverAnchor() { return m_ERRObserverAnchor; }
|
||||
IEntity ERR_ObservedEntity()
|
||||
{
|
||||
RplComponent component = RplComponent.Cast(Replication.FindItem(m_ERRObservedId));
|
||||
if (!component) return null;
|
||||
return component.GetEntity();
|
||||
}
|
||||
bool ERR_HasObserverCamera() { return m_ERRObserverCamera != null; }
|
||||
|
||||
void ERR_SetObserver(ERR_ObserverReason reason)
|
||||
{
|
||||
if (!Replication.IsServer()) return;
|
||||
if (reason != ERR_ObserverReason.NONE && ERR_Actors.Alive(GetControlledEntity()) && reason != ERR_ObserverReason.UNCONSCIOUS) return;
|
||||
m_ERRObserverReason = reason;
|
||||
ERR_GameMode mode = ERR_GameMode.Get();
|
||||
if (mode && reason != ERR_ObserverReason.NONE) m_ERRObserverAnchor = mode.PrisonPosition();
|
||||
if (reason == ERR_ObserverReason.NONE) ERR_ReleaseObserved();
|
||||
Replication.BumpMe();
|
||||
ERR_UpdateObserverServer();
|
||||
}
|
||||
|
||||
protected void ERR_StreamObserved(IEntity entity, bool normalStreaming)
|
||||
{
|
||||
RplIdentity identity = GetRplIdentity();
|
||||
if (!identity.IsValid() || identity == RplIdentity.Local() || !entity) return;
|
||||
RplComponent component = RplComponent.Cast(entity.FindComponent(RplComponent));
|
||||
if (component) component.EnableStreamingConNode(identity, normalStreaming);
|
||||
}
|
||||
protected void ERR_ReleaseObserved()
|
||||
{
|
||||
ERR_StreamObserved(m_ERRServerObserved, true);
|
||||
ERR_StreamObserved(m_ERRServerObservedVehicle, true);
|
||||
m_ERRServerObserved = null;
|
||||
m_ERRServerObservedVehicle = null;
|
||||
m_ERRObservedId = RplId.Invalid();
|
||||
}
|
||||
void ERR_UpdateObserverServer(int direction = 0)
|
||||
{
|
||||
if (!Replication.IsServer()) return;
|
||||
ERR_GameMode mode = ERR_GameMode.Get();
|
||||
IEntity body = GetControlledEntity();
|
||||
bool casualty = mode && ERR_ObserverRules.Casualty(mode.Active(), mode.Players() && mode.Players().CurrentActor(body) != null, ERR_Actors.Alive(body), ERR_Actors.CanFight(body));
|
||||
if (casualty && m_ERRObserverReason != ERR_ObserverReason.UNCONSCIOUS)
|
||||
{
|
||||
m_ERRObserverReason = ERR_ObserverReason.UNCONSCIOUS;
|
||||
m_ERRObserverAnchor = body.GetOrigin();
|
||||
}
|
||||
if (m_ERRObserverReason == ERR_ObserverReason.UNCONSCIOUS && !ERR_Actors.Alive(body)) m_ERRObserverReason = ERR_ObserverReason.SPENT_LIFE;
|
||||
// Death can leave the controller attached to its corpse, so entity-lost is
|
||||
// not a reliable trigger (especially with the last/only player).
|
||||
ERR_PlayerLife life;
|
||||
if (mode && mode.Players()) life = mode.Players().Get(GetPlayerId());
|
||||
if (mode && mode.Active() && life && life.UsedLife && life.Actor && !ERR_Actors.Alive(life.Actor) && !ERR_Actors.Alive(body) && m_ERRObserverReason == ERR_ObserverReason.NONE)
|
||||
{
|
||||
m_ERRObserverReason = ERR_ObserverReason.SPENT_LIFE;
|
||||
m_ERRObserverAnchor = life.Actor.GetOrigin();
|
||||
}
|
||||
if (m_ERRObserverReason == ERR_ObserverReason.NONE) return;
|
||||
if (!mode || !mode.Active() || (ERR_Actors.Alive(body) && !casualty) || (m_ERRObserverReason == ERR_ObserverReason.UNCONSCIOUS && !casualty))
|
||||
{
|
||||
m_ERRObserverReason = ERR_ObserverReason.NONE;
|
||||
ERR_ReleaseObserved();
|
||||
Replication.BumpMe();
|
||||
return;
|
||||
}
|
||||
array<IEntity> survivors = {};
|
||||
ERR_Actors.Players(survivors);
|
||||
Faction friendly = mode.PlayerFaction();
|
||||
for (int i = survivors.Count() - 1; i >= 0; i--)
|
||||
if (survivors[i] == body || (casualty && !ERR_Actors.CanFight(survivors[i])) || !friendly || !mode.Players() || !mode.Players().CurrentActor(survivors[i]) || SCR_FactionManager.SGetFaction(survivors[i]) != friendly) survivors.RemoveOrdered(i);
|
||||
// Dead teammates remain selectable alongside survivors, including our own body.
|
||||
ERR_ObserverTargets.Bodies(mode, survivors);
|
||||
int index = survivors.Find(m_ERRServerObserved);
|
||||
if (index < 0)
|
||||
{
|
||||
if (life && life.Actor) index = survivors.Find(life.Actor);
|
||||
if (index < 0) index = 0;
|
||||
}
|
||||
else index += direction;
|
||||
if (index < 0) index = survivors.Count() - 1;
|
||||
if (index >= survivors.Count()) index = 0;
|
||||
IEntity target;
|
||||
if (!survivors.IsEmpty()) target = survivors[index];
|
||||
if (target != m_ERRServerObserved)
|
||||
{
|
||||
ERR_ReleaseObserved();
|
||||
m_ERRServerObserved = target;
|
||||
if (target)
|
||||
{
|
||||
RplComponent component = RplComponent.Cast(target.FindComponent(RplComponent));
|
||||
if (component) m_ERRObservedId = component.Id();
|
||||
ERR_StreamObserved(target, false);
|
||||
}
|
||||
}
|
||||
if (target)
|
||||
{
|
||||
m_ERRObserverAnchor = target.GetOrigin();
|
||||
IEntity vehicle = CompartmentAccessComponent.GetVehicleIn(target);
|
||||
if (vehicle != m_ERRServerObservedVehicle)
|
||||
{
|
||||
ERR_StreamObserved(m_ERRServerObservedVehicle, true);
|
||||
m_ERRServerObservedVehicle = vehicle;
|
||||
ERR_StreamObserved(vehicle, false);
|
||||
}
|
||||
}
|
||||
else if (casualty) m_ERRObserverAnchor = body.GetOrigin();
|
||||
Replication.BumpMe();
|
||||
}
|
||||
void ERR_CycleObserver(int direction)
|
||||
{
|
||||
if (this == GetGame().GetPlayerController()) Rpc(ERR_RequestObserverCycle, direction);
|
||||
}
|
||||
[RplRpc(RplChannel.Reliable, RplRcver.Server)]
|
||||
protected void ERR_RequestObserverCycle(int direction)
|
||||
{
|
||||
if (!Replication.IsServer() || (direction != -1 && direction != 1)) return;
|
||||
float now = GetGame().GetWorld().GetWorldTime();
|
||||
if (now - m_ERRLastCycle < 250) return;
|
||||
m_ERRLastCycle = now;
|
||||
ERR_UpdateObserverServer(direction);
|
||||
}
|
||||
|
||||
void ERR_StartObserverMonitor()
|
||||
{
|
||||
if (this != GetGame().GetPlayerController() || System.IsConsoleApp() || m_ERRObserverMonitor) return;
|
||||
m_ERRObserverMonitor = true;
|
||||
ERR_OnObserverMonitorStarted();
|
||||
GetGame().GetCallqueue().CallLater(ERR_UpdateObserverClient, 250, true);
|
||||
}
|
||||
protected void ERR_OnObserverMonitorStarted() {}
|
||||
protected void ERR_UpdateObserverClient()
|
||||
{
|
||||
if (!GetGame()) return;
|
||||
if (this != GetGame().GetPlayerController())
|
||||
{
|
||||
// A replaced controller may retain its timer until native destruction.
|
||||
// Release only its presentation; never restore its old body camera.
|
||||
GetGame().GetCallqueue().Remove(ERR_UpdateObserverClient);
|
||||
m_ERRObserverMonitor = false;
|
||||
m_ERRRecoveryBody = null; m_ERRRecoveryDeadline = -1;
|
||||
ERR_CloseObserverCamera();
|
||||
return;
|
||||
}
|
||||
ERR_GameMode mode = ERR_GameMode.Get();
|
||||
ERR_UpdateRecoveryCamera(mode);
|
||||
bool observe = mode && ERR_ObserverRules.View(m_ERRObserverReason, mode.Active(), ERR_Actors.Alive(GetControlledEntity()), ERR_Actors.CanFight(GetControlledEntity()));
|
||||
if (!observe)
|
||||
{
|
||||
ERR_CloseObserverCamera();
|
||||
return;
|
||||
}
|
||||
SCR_EditorManagerEntity editor = SCR_EditorManagerEntity.GetInstance();
|
||||
if (editor && editor.IsOpened()) return;
|
||||
if (!m_ERRObserverCamera)
|
||||
{
|
||||
Resource resource = Resource.Load("{EB1210AAE1BB8F54}Prefabs/ERR/SpectatorCamera.et");
|
||||
if (!resource || !resource.IsValid()) return;
|
||||
EntitySpawnParams params = new EntitySpawnParams();
|
||||
params.TransformMode = ETransformMode.WORLD;
|
||||
params.Transform[3] = m_ERRObserverAnchor + "0 5 -6";
|
||||
m_ERRObserverCamera = SCR_ManualCamera.Cast(GetGame().SpawnEntityPrefabLocal(resource, GetGame().GetWorld(), params));
|
||||
if (!m_ERRObserverCamera) return;
|
||||
}
|
||||
CameraManager manager = GetGame().GetCameraManager();
|
||||
if (!manager) return;
|
||||
// Native death/respawn camera changes can arrive after our camera is created.
|
||||
if (manager.CurrentCamera() != m_ERRObserverCamera) manager.SetCamera(m_ERRObserverCamera);
|
||||
if (manager.CurrentCamera() != m_ERRObserverCamera) return;
|
||||
SCR_RespawnSystemComponent respawn = SCR_RespawnSystemComponent.GetInstance();
|
||||
if (respawn) respawn.DestroyLoadingPlaceholder();
|
||||
// The server-selected replicated ID distinguishes no target from a target
|
||||
// whose local entity is still streaming. Never imply no survivors from delay.
|
||||
bool survivorAvailable = m_ERRObservedId != RplId.Invalid();
|
||||
string recovery = mode.RecoveryStatus();
|
||||
if (ERR_ObserverGuidance.Refresh(m_ERRObserverReady, m_ERRShownObserverReason, m_ERRObserverReason, m_ERRShownObserverAvailable, survivorAvailable) || m_ERRShownRecoveryStatus != recovery)
|
||||
{
|
||||
SCR_HintManagerComponent hints = SCR_HintManagerComponent.GetInstance();
|
||||
if (!hints) return; // retry on the existing monitor; do not lose the first hint
|
||||
string message = ERR_ObserverGuidance.Message(m_ERRObserverReason, survivorAvailable);
|
||||
if (!recovery.IsEmpty()) message += "\n" + recovery;
|
||||
if (!hints.ShowCustom(message, "Spectating", 12)) return;
|
||||
m_ERRObserverReady = true;
|
||||
m_ERRShownObserverReason = m_ERRObserverReason;
|
||||
m_ERRShownObserverAvailable = survivorAvailable;
|
||||
m_ERRShownRecoveryStatus = recovery;
|
||||
}
|
||||
}
|
||||
// Track the actual local body even when an editor/other camera owns the view.
|
||||
// Run once per recovery, with retries for asynchronous editor close/camera setup.
|
||||
protected void ERR_UpdateRecoveryCamera(ERR_GameMode mode)
|
||||
{
|
||||
IEntity body = GetControlledEntity();
|
||||
if (!mode || !mode.Active() || !ERR_Actors.Alive(body))
|
||||
{ m_ERRRecoveryBody = null; m_ERRRecoveryDeadline = -1; return; }
|
||||
if (!ERR_Actors.CanFight(body))
|
||||
{ m_ERRRecoveryBody = body; m_ERRRecoveryDeadline = -1; return; }
|
||||
if (!m_ERRRecoveryBody) return;
|
||||
if (body != m_ERRRecoveryBody)
|
||||
{ m_ERRRecoveryBody = null; m_ERRRecoveryDeadline = -1; return; }
|
||||
float now = GetGame().GetWorld().GetWorldTime();
|
||||
if (m_ERRRecoveryDeadline < 0) m_ERRRecoveryDeadline = now + 10000;
|
||||
SCR_EditorManagerEntity editor = SCR_EditorManagerEntity.GetInstance();
|
||||
if (editor && editor.IsOpened()) editor.Close(false);
|
||||
CameraManager manager = GetGame().GetCameraManager();
|
||||
CameraBase playerCamera = GetPlayerCamera();
|
||||
if (manager && playerCamera) manager.SetCamera(playerCamera);
|
||||
if ((manager && playerCamera && manager.CurrentCamera() == playerCamera && (!editor || !editor.IsOpened())) || now >= m_ERRRecoveryDeadline)
|
||||
{
|
||||
if (now >= m_ERRRecoveryDeadline) Print("Reintroduced [spectator]: recovery camera return timed out waiting for editor/camera; player body remains controlled", LogLevel.WARNING);
|
||||
m_ERRRecoveryBody = null; m_ERRRecoveryDeadline = -1;
|
||||
}
|
||||
}
|
||||
protected void ERR_CloseObserverCamera()
|
||||
{
|
||||
m_ERRObserverReady = false;
|
||||
m_ERRShownRecoveryStatus = "";
|
||||
if (!m_ERRObserverCamera) return;
|
||||
if (GetGame().GetPlayerController() == this) m_ERRObserverCamera.TrySwitchToControlledEntityCamera();
|
||||
m_ERRObserverCamera.Terminate();
|
||||
delete m_ERRObserverCamera;
|
||||
m_ERRObserverCamera = null;
|
||||
}
|
||||
void ~SCR_PlayerController()
|
||||
{
|
||||
if (GetGame())
|
||||
{
|
||||
GetGame().GetCallqueue().Remove(ERR_UpdateObserverClient);
|
||||
ERR_CloseObserverCamera();
|
||||
if (Replication.IsServer()) ERR_ReleaseObserved();
|
||||
}
|
||||
}
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
// Camera poses are cosmetic, owner-sampled and relayed only to authorized
|
||||
// spectators of that body. No client-supplied position affects gameplay.
|
||||
class ERR_ObserverViewRules
|
||||
{
|
||||
static bool Sample(vector position, vector forward, vector up, float fov, float nearPlane)
|
||||
{
|
||||
// Negated bounded comparisons also reject NaN/infinity before matrix math.
|
||||
if (!(position.LengthSq() <= 900) || !(fov >= 1 && fov <= 150) || !(nearPlane >= 0.005 && nearPlane <= 2)) return false;
|
||||
if (!(forward.LengthSq() >= 0.9 && forward.LengthSq() <= 1.1) || !(up.LengthSq() >= 0.9 && up.LengthSq() <= 1.1)) return false;
|
||||
return Math.AbsFloat(vector.Dot(forward, up)) <= 0.05;
|
||||
}
|
||||
static bool Fresh(float now, float received) { return received >= 0 && now >= received && now - received <= 350; }
|
||||
}
|
||||
class ERR_ObserverViewBuffer
|
||||
{
|
||||
protected RplId m_Target = RplId.Invalid();
|
||||
protected int m_Stamp = -1;
|
||||
protected RplId m_ScopeWeapon = RplId.Invalid();
|
||||
protected vector m_ScopeData;
|
||||
void Scope(out RplId weapon, out vector data) { weapon = m_ScopeWeapon; data = m_ScopeData; }
|
||||
protected float m_Received = -1;
|
||||
protected vector m_Position, m_Forward, m_Up;
|
||||
protected vector m_StartPosition, m_StartForward, m_StartUp;
|
||||
protected float m_Fov, m_Near, m_StartFov, m_Blend = 1;
|
||||
void Clear() { m_Target = RplId.Invalid(); m_Stamp = -1; m_Received = -1; }
|
||||
void Push(RplId target, int stamp, float now, vector position, vector forward, vector up, float fov, float nearPlane, RplId scopeWeapon, vector scopeData)
|
||||
{
|
||||
if (!ERR_ObserverScope.Valid(scopeWeapon, scopeData)) return;
|
||||
if (!ERR_ObserverViewRules.Sample(position, forward, up, fov, nearPlane)) return;
|
||||
if (target == m_Target && stamp <= m_Stamp) return;
|
||||
if (target != m_Target || scopeWeapon != m_ScopeWeapon || scopeData[0] != m_ScopeData[0] || !ERR_ObserverViewRules.Fresh(now, m_Received))
|
||||
{ m_StartPosition = position; m_StartForward = forward; m_StartUp = up; m_StartFov = fov; }
|
||||
else
|
||||
{
|
||||
m_StartPosition = vector.Lerp(m_StartPosition, m_Position, m_Blend);
|
||||
m_StartForward = vector.Lerp(m_StartForward, m_Forward, m_Blend);
|
||||
m_StartUp = vector.Lerp(m_StartUp, m_Up, m_Blend);
|
||||
m_StartFov = Math.Lerp(m_StartFov, m_Fov, m_Blend);
|
||||
}
|
||||
m_ScopeWeapon = scopeWeapon; m_ScopeData = scopeData;
|
||||
m_Target = target; m_Stamp = stamp; m_Received = now; m_Blend = 0;
|
||||
m_Position = position; m_Forward = forward; m_Up = up; m_Fov = fov; m_Near = nearPlane;
|
||||
}
|
||||
bool Read(RplId target, float now, float seconds, out vector local[4], out float fov, out float nearPlane)
|
||||
{
|
||||
if (target != m_Target || !ERR_ObserverViewRules.Fresh(now, m_Received)) return false;
|
||||
m_Blend = Math.Clamp(m_Blend + Math.Max(0, seconds) / 0.05, 0, 1);
|
||||
vector forward = vector.Lerp(m_StartForward, m_Forward, m_Blend);
|
||||
vector up = vector.Lerp(m_StartUp, m_Up, m_Blend);
|
||||
if (forward.LengthSq() < 0.1 || up.LengthSq() < 0.1) { forward = m_Forward; up = m_Up; }
|
||||
forward.Normalize(); up.Normalize();
|
||||
if (Math.AbsFloat(vector.Dot(forward, up)) > 0.95) { forward = m_Forward; up = m_Up; }
|
||||
Math3D.DirectionAndUpMatrix(forward, up, local);
|
||||
local[3] = vector.Lerp(m_StartPosition, m_Position, m_Blend);
|
||||
fov = Math.Lerp(m_StartFov, m_Fov, m_Blend); nearPlane = m_Near;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
modded class SCR_PlayerController
|
||||
{
|
||||
protected ref ERR_ObserverViewBuffer m_ERRViewBuffer = new ERR_ObserverViewBuffer();
|
||||
protected ref array<SCR_PlayerController> m_ERRViewWatchers = {};
|
||||
protected float m_ERRViewServerRequestAt = -1000;
|
||||
protected float m_ERRViewRequestAt = -1000, m_ERRViewLeaseUntil = -1;
|
||||
protected float m_ERRViewPublishUntil = -1, m_ERRViewLeaseSent = -1000;
|
||||
protected float m_ERRViewSendWait, m_ERRViewLastPacket = -1000;
|
||||
protected int m_ERRViewSequence, m_ERRViewInputSequence;
|
||||
protected IEntity m_ERRViewSourceBody;
|
||||
bool ERR_ViewRecipient(IEntity target, float now)
|
||||
{
|
||||
ERR_GameMode mode = ERR_GameMode.Get();
|
||||
return mode && mode.Active() && now <= m_ERRViewLeaseUntil && target && target == m_ERRServerObserved && ERR_Actors.CanFight(target) && mode.Players().CurrentActor(target) && SCR_FactionManager.SGetFaction(target) == mode.PlayerFaction() && GetGame().GetPlayerManager().GetPlayerController(GetPlayerId()) == this && ERR_ObserverRules.View(m_ERRObserverReason, true, ERR_Actors.Alive(GetControlledEntity()), ERR_Actors.CanFight(GetControlledEntity()));
|
||||
}
|
||||
void ERR_ReadScope(out RplId weapon, out vector data) { m_ERRViewBuffer.Scope(weapon, data); }
|
||||
bool ERR_ReadPlayerView(IEntity target, float seconds, out vector transform[4], out float fov, out float nearPlane)
|
||||
{
|
||||
if (this != GetGame().GetPlayerController() || !target || !ERR_Actors.CanFight(target)) return false;
|
||||
float now = GetGame().GetWorld().GetWorldTime();
|
||||
if (now - m_ERRViewRequestAt >= 500)
|
||||
{ m_ERRViewRequestAt = now; Rpc(ERR_RequestPlayerView); }
|
||||
vector local[4], world[4];
|
||||
if (!m_ERRViewBuffer.Read(m_ERRObservedId, now, seconds, local, fov, nearPlane)) return false;
|
||||
RplId scopeWeapon; vector scopeData;
|
||||
m_ERRViewBuffer.Scope(scopeWeapon, scopeData);
|
||||
if (scopeWeapon != RplId.Invalid() && !ERR_ObserverScope.Resolve(target, scopeWeapon, scopeData)) return false;
|
||||
target.GetWorldTransform(world);
|
||||
Math3D.MatrixMultiply4(world, local, transform);
|
||||
return true;
|
||||
}
|
||||
[RplRpc(RplChannel.Reliable, RplRcver.Server)]
|
||||
protected void ERR_RequestPlayerView()
|
||||
{
|
||||
if (!Replication.IsServer()) return;
|
||||
float now = GetGame().GetWorld().GetWorldTime();
|
||||
if (now - m_ERRViewServerRequestAt < 250) return;
|
||||
m_ERRViewServerRequestAt = now;
|
||||
// A lease grants no observer rights; all existing server selection/roster
|
||||
// checks still apply, both here and when each sample is forwarded.
|
||||
m_ERRViewLeaseUntil = now + 1500;
|
||||
if (!ERR_ViewRecipient(m_ERRServerObserved, now)) return;
|
||||
ERR_PlayerLife life = ERR_GameMode.Get().Players().CurrentActor(m_ERRServerObserved);
|
||||
SCR_PlayerController source = SCR_PlayerController.Cast(GetGame().GetPlayerManager().GetPlayerController(life.ConnectionId));
|
||||
if (source) source.ERR_AddViewWatcher(this, now);
|
||||
}
|
||||
void ERR_AddViewWatcher(SCR_PlayerController watcher, float now)
|
||||
{
|
||||
if (!Replication.IsServer() || !watcher) return;
|
||||
for (int i = m_ERRViewWatchers.Count() - 1; i >= 0; i--)
|
||||
if (!m_ERRViewWatchers[i] || !m_ERRViewWatchers[i].ERR_ViewRecipient(GetControlledEntity(), now)) m_ERRViewWatchers.Remove(i);
|
||||
if (!m_ERRViewWatchers.Contains(watcher) && m_ERRViewWatchers.Count() < 64) m_ERRViewWatchers.Insert(watcher);
|
||||
if (now - m_ERRViewLeaseSent < 250) return;
|
||||
m_ERRViewLeaseSent = now;
|
||||
Rpc(ERR_EnableViewPublisher);
|
||||
}
|
||||
[RplRpc(RplChannel.Reliable, RplRcver.Owner)]
|
||||
protected void ERR_EnableViewPublisher()
|
||||
{
|
||||
if (this == GetGame().GetPlayerController()) m_ERRViewPublishUntil = GetGame().GetWorld().GetWorldTime() + 2000;
|
||||
}
|
||||
void ERR_PublishPlayerView(float seconds)
|
||||
{
|
||||
if (this != GetGame().GetPlayerController() || System.IsConsoleApp()) return;
|
||||
float now = GetGame().GetWorld().GetWorldTime();
|
||||
if (now > m_ERRViewPublishUntil) return;
|
||||
m_ERRViewSendWait += seconds;
|
||||
if (m_ERRViewSendWait < 0.05) return;
|
||||
m_ERRViewSendWait = 0;
|
||||
ERR_GameMode mode = ERR_GameMode.Get();
|
||||
IEntity actor = GetControlledEntity();
|
||||
PlayerCamera camera = GetPlayerCamera();
|
||||
CameraManager manager = GetGame().GetCameraManager();
|
||||
if (!mode || !mode.Active() || !ERR_Actors.CanFight(actor) || !camera || !manager || manager.CurrentCamera() != camera) return;
|
||||
vector world[4], view[4], local[4];
|
||||
actor.GetWorldTransform(world);
|
||||
camera.GetWorldCameraTransform(view);
|
||||
float fov = camera.GetVerticalFOV(), nearPlane = camera.GetNearPlane();
|
||||
RplId scopeWeapon; vector scopeData;
|
||||
ERR_ObserverScope.Sample(actor, view, fov, scopeWeapon, scopeData);
|
||||
Math3D.MatrixInvMultiply4(world, view, local);
|
||||
if (!ERR_ObserverViewRules.Sample(local[3], local[2], local[1], fov, nearPlane)) return;
|
||||
Rpc(ERR_SubmitPlayerView, ++m_ERRViewSequence, local[3], local[2], local[1], fov, nearPlane, scopeWeapon, scopeData);
|
||||
}
|
||||
[RplRpc(RplChannel.Unreliable, RplRcver.Server)]
|
||||
protected void ERR_SubmitPlayerView(int sequence, vector position, vector forward, vector up, float fov, float nearPlane, RplId scopeWeapon, vector scopeData)
|
||||
{
|
||||
if (!Replication.IsServer() || !ERR_ObserverScope.Valid(scopeWeapon, scopeData) || !ERR_ObserverViewRules.Sample(position, forward, up, fov, nearPlane)) return;
|
||||
ERR_GameMode mode = ERR_GameMode.Get();
|
||||
IEntity actor = GetControlledEntity();
|
||||
if (!mode || !mode.Active() || !ERR_Actors.CanFight(actor)) return;
|
||||
ERR_PlayerLife life = mode.Players().CurrentActor(actor);
|
||||
if (!life || GetGame().GetPlayerManager().GetPlayerController(life.ConnectionId) != this) return;
|
||||
float now = GetGame().GetWorld().GetWorldTime();
|
||||
if (actor != m_ERRViewSourceBody) { m_ERRViewSourceBody = actor; m_ERRViewInputSequence = 0; }
|
||||
if (sequence <= m_ERRViewInputSequence || now - m_ERRViewLastPacket < 35) return;
|
||||
m_ERRViewInputSequence = sequence; m_ERRViewLastPacket = now;
|
||||
RplComponent component = RplComponent.Cast(actor.FindComponent(RplComponent));
|
||||
if (!component) return;
|
||||
for (int i = m_ERRViewWatchers.Count() - 1; i >= 0; i--)
|
||||
{
|
||||
SCR_PlayerController watcher = m_ERRViewWatchers[i];
|
||||
if (!watcher || !watcher.ERR_ViewRecipient(actor, now)) { m_ERRViewWatchers.Remove(i); continue; }
|
||||
watcher.ERR_DeliverPlayerView(component.Id(), now, position, forward, up, fov, nearPlane, scopeWeapon, scopeData);
|
||||
}
|
||||
}
|
||||
void ERR_DeliverPlayerView(RplId target, int stamp, vector position, vector forward, vector up, float fov, float nearPlane, RplId scopeWeapon, vector scopeData)
|
||||
{
|
||||
Rpc(ERR_ReceivePlayerViewPacket, target, stamp, position, forward, up, Vector(fov, nearPlane, 0), scopeWeapon, scopeData);
|
||||
}
|
||||
[RplRpc(RplChannel.Unreliable, RplRcver.Owner)]
|
||||
protected void ERR_ReceivePlayerViewPacket(RplId target, int stamp, vector position, vector forward, vector up, vector lens, RplId scopeWeapon, vector scopeData)
|
||||
{
|
||||
ERR_ReceivePlayerView(target, stamp, position, forward, up, lens[0], lens[1], scopeWeapon, scopeData);
|
||||
}
|
||||
protected void ERR_ReceivePlayerView(RplId target, int stamp, vector position, vector forward, vector up, float fov, float nearPlane, RplId scopeWeapon, vector scopeData)
|
||||
{
|
||||
ERR_GameMode mode = ERR_GameMode.Get();
|
||||
if (this != GetGame().GetPlayerController() || target == RplId.Invalid() || target != m_ERRObservedId || !mode || !ERR_ObserverRules.View(m_ERRObserverReason, mode.Active(), ERR_Actors.Alive(GetControlledEntity()), ERR_Actors.CanFight(GetControlledEntity()))) return;
|
||||
m_ERRViewBuffer.Push(target, stamp, GetGame().GetWorld().GetWorldTime(), position, forward, up, fov, nearPlane, scopeWeapon, scopeData);
|
||||
}
|
||||
}
|
||||
modded class ArmaReforgerScripted
|
||||
{
|
||||
override void OnUpdate(BaseWorld world, float timeslice)
|
||||
{
|
||||
super.OnUpdate(world, timeslice);
|
||||
SCR_PlayerController controller = SCR_PlayerController.Cast(GetPlayerController());
|
||||
if (controller) controller.ERR_PublishPlayerView(timeslice);
|
||||
}
|
||||
}
|
||||
|
||||
// Use the game's own head/helmet fade rather than moving the camera ahead of
|
||||
// the face. Changes are local and reversed when the observer stops using them.
|
||||
class ERR_ObserverHeadVisibility
|
||||
{
|
||||
protected SCR_ChimeraCharacter m_Character;
|
||||
protected SCR_CharacterCameraHandlerComponent m_Handler;
|
||||
protected int m_Previous, m_Applied;
|
||||
void Update(IEntity target, vector cameraPosition)
|
||||
{
|
||||
if (target != m_Character)
|
||||
{
|
||||
Restore();
|
||||
m_Character = SCR_ChimeraCharacter.Cast(target);
|
||||
if (!m_Character || !m_Character.GetCharacterController()) return;
|
||||
m_Handler = SCR_CharacterCameraHandlerComponent.Cast(m_Character.GetCharacterController().GetCameraHandlerComponent());
|
||||
m_Previous = m_Character.m_fFaceAlphaTest;
|
||||
}
|
||||
if (!m_Handler || !m_Character.GetAnimation()) return;
|
||||
m_Handler.UpdateHeadVisibility(cameraPosition);
|
||||
m_Applied = m_Character.m_fFaceAlphaTest;
|
||||
}
|
||||
void Restore()
|
||||
{
|
||||
if (m_Handler && m_Character && m_Character.m_fFaceAlphaTest == m_Applied) m_Handler.OnAlphatestChange(m_Previous);
|
||||
m_Character = null; m_Handler = null;
|
||||
}
|
||||
void ~ERR_ObserverHeadVisibility() { Restore(); }
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
// Presentation only. Scoped spectators use a private full-screen optic display;
|
||||
// never activate the remote sight (which registers weapon input and changes HUD).
|
||||
class ERR_ObserverScope
|
||||
{
|
||||
static bool Valid(RplId weapon, vector data)
|
||||
{
|
||||
if (weapon == RplId.Invalid()) return data == vector.Zero;
|
||||
return data[0] >= 1 && data[0] <= 32 && data[0] == Math.Round(data[0]) && data[1] >= -90 && data[1] <= 90 && (data[2] == 0 || data[2] == 1);
|
||||
}
|
||||
static void Sample(IEntity actor, out vector view[4], out float fov, out RplId weaponId, out vector data)
|
||||
{
|
||||
weaponId = RplId.Invalid(); data = vector.Zero;
|
||||
ChimeraCharacter character = ChimeraCharacter.Cast(actor);
|
||||
if (!character) return;
|
||||
CharacterControllerComponent control = character.GetCharacterController();
|
||||
if (!control || !control.IsWeaponADS()) return;
|
||||
BaseWeaponManagerComponent weapons = control.GetWeaponManagerComponent();
|
||||
if (!weapons || !weapons.GetCurrentWeapon()) return;
|
||||
SCR_2DOpticsComponent optic = SCR_2DOpticsComponent.Cast(weapons.GetCurrentSights());
|
||||
if (!optic || !optic.IsSightADSActive()) return;
|
||||
RplComponent rpl = RplComponent.Cast(weapons.GetCurrentWeapon().GetOwner().FindComponent(RplComponent));
|
||||
if (!rpl || rpl.Id() == RplId.Invalid()) return;
|
||||
float scopeFov = optic.GetFOV();
|
||||
if (!(scopeFov >= 1 && scopeFov <= 150)) return;
|
||||
vector sample = Vector(weapons.GetCurrentSightsIndex() + 1, optic.GetCurrentReticleOffsetY(), 0);
|
||||
if (optic.ERR_ObserverIlluminated()) sample[2] = 1;
|
||||
if (!Valid(rpl.Id(), sample)) return;
|
||||
// A PiP sight has its own sight-line camera. Use that direction rather than
|
||||
// magnifying the peripheral eye camera, which need not point down the scope.
|
||||
SCR_2DPIPSightsComponent pip = SCR_2DPIPSightsComponent.Cast(optic);
|
||||
if (pip && pip.IsPIPEnabled())
|
||||
{
|
||||
if (!pip.GetPIPCamera()) return;
|
||||
pip.GetPIPCamera().GetWorldCameraTransform(view);
|
||||
}
|
||||
weaponId = rpl.Id(); data = sample; fov = scopeFov;
|
||||
}
|
||||
static SCR_2DOpticsComponent Resolve(IEntity target, RplId weaponId, vector data)
|
||||
{
|
||||
if (weaponId == RplId.Invalid() || !Valid(weaponId, data)) return null;
|
||||
ChimeraCharacter character = ChimeraCharacter.Cast(target);
|
||||
if (!character || !character.GetCharacterController()) return null;
|
||||
BaseWeaponManagerComponent weapons = character.GetCharacterController().GetWeaponManagerComponent();
|
||||
if (!weapons || !weapons.GetCurrentWeapon() || weapons.GetCurrentSightsIndex() + 1 != data[0]) return null;
|
||||
RplComponent rpl = RplComponent.Cast(weapons.GetCurrentWeapon().GetOwner().FindComponent(RplComponent));
|
||||
if (!rpl || rpl.Id() != weaponId) return null;
|
||||
return SCR_2DOpticsComponent.Cast(weapons.GetCurrentSights());
|
||||
}
|
||||
}
|
||||
modded class SCR_2DOpticsComponent
|
||||
{
|
||||
bool ERR_ObserverIlluminated() { return m_bIsIlluminationOn; }
|
||||
}
|
||||
|
||||
// Reuse the native reticle textures, angular sizing and vignette layout, without
|
||||
// registering with the player's HUD, ADS invokers, weapon input or visibility.
|
||||
class ERR_ObserverScopeDisplay : SCR_2DOpticsDisplay
|
||||
{
|
||||
protected ref Widget m_ERRScopeRoot; // Native HUD normally owns this strong reference.
|
||||
protected IEntity m_ERRHiddenWeapon, m_ERRHiddenOptic;
|
||||
protected float m_ERRFov;
|
||||
bool RenderScope(SCR_2DOpticsComponent optic, IEntity weapon, float fov, vector data)
|
||||
{
|
||||
if (!optic || !weapon) { Close(); return false; }
|
||||
if (!m_wRoot)
|
||||
{
|
||||
m_ERRScopeRoot = GetGame().GetWorkspace().CreateWidgets("{4CE66FA8219D33D7}UI/layouts/Sights/Optic_Default.layout");
|
||||
m_wRoot = m_ERRScopeRoot;
|
||||
if (!m_wRoot) return false;
|
||||
FindWidgets();
|
||||
if (!m_wLayoutRear || !m_wLayoutFront || !m_wImgReticle || !m_wImgCover || !m_wBlur || !m_wRearFillLeft || !m_wRearFillTop || !m_wFrontFillLeft || !m_wFrontFillTop) { Close(); return false; }
|
||||
m_wRoot.SetFlags(WidgetFlags.IGNORE_CURSOR);
|
||||
m_wRoot.SetZOrder(1); // Standalone workspace root must be above the game viewport.
|
||||
}
|
||||
m_ERRFov = fov;
|
||||
if (m_Optic != optic)
|
||||
{
|
||||
RestoreWeapon();
|
||||
m_Optic = optic;
|
||||
GetOpticData();
|
||||
SetupOpticImage();
|
||||
// Hide only this locally rendered weapon, never the soldier or inventory.
|
||||
if ((weapon.GetFlags() & EntityFlags.VISIBLE) != 0)
|
||||
{ m_ERRHiddenWeapon = weapon; weapon.ClearFlags(EntityFlags.VISIBLE, false); }
|
||||
IEntity opticOwner = optic.GetOwner();
|
||||
if (opticOwner != weapon && (opticOwner.GetFlags() & EntityFlags.VISIBLE) != 0)
|
||||
{ m_ERRHiddenOptic = opticOwner; opticOwner.ClearFlags(EntityFlags.VISIBLE, false); }
|
||||
}
|
||||
UpdateCurrentFov();
|
||||
// Native layout uses fill widgets rather than centered anchors. Recompute
|
||||
// these for resolution/DPI changes, without adding remote animation sway.
|
||||
WorkspaceWidget workspace = GetGame().GetWorkspace();
|
||||
float width, height;
|
||||
workspace.GetScreenSize(width, height);
|
||||
width = workspace.DPIUnscale(width); height = workspace.DPIUnscale(height);
|
||||
UpdateScale(1, 1, 1);
|
||||
m_wFrontFillLeft.SetWidthOverride(LayerDefaultPosition(width, m_fObjectiveSize));
|
||||
m_wFrontFillTop.SetHeightOverride(LayerDefaultPosition(height, m_fObjectiveSize));
|
||||
m_wRearFillLeft.SetWidthOverride(LayerDefaultPosition(width, m_fVignetteSize));
|
||||
m_wRearFillTop.SetHeightOverride(LayerDefaultPosition(height, m_fVignetteSize));
|
||||
SCR_2DOpticsComponentClass settings = SCR_2DOpticsComponentClass.Cast(optic.GetComponentData(optic.GetOwner()));
|
||||
if (settings)
|
||||
{
|
||||
if (data[2] == 1 && settings.HasIllumination()) ChangeReticleTint(settings.GetReticleIlluminationColor(), settings.GetReticleIlluminationColor());
|
||||
else ChangeReticleTint(settings.GetReticleColor(), settings.GetReticleOutlineColor());
|
||||
}
|
||||
// The relayed pose is already the aiming camera; don't add another local
|
||||
// weapon-animation sway (remote proxies can be several frames behind).
|
||||
SetReticleOffset(optic.GetReticleOffsetX(), data[1]);
|
||||
m_wImgCover.SetVisible(false); m_wBlur.SetIntensity(0); m_wBlur.SetOpacity(0);
|
||||
m_wRoot.SetOpacity(1); m_wRoot.SetVisible(true);
|
||||
return true;
|
||||
}
|
||||
override void UpdateCurrentFov(bool forceUpdate = false)
|
||||
{
|
||||
if (!forceUpdate && float.AlmostEqual(m_fOpticCurrentFov, m_ERRFov)) return;
|
||||
m_fOpticCurrentFov = m_ERRFov;
|
||||
UpdateReticleSize();
|
||||
}
|
||||
protected void RestoreWeapon()
|
||||
{
|
||||
if (m_ERRHiddenWeapon && !m_ERRHiddenWeapon.IsDeleted()) m_ERRHiddenWeapon.SetFlags(EntityFlags.VISIBLE, false);
|
||||
if (m_ERRHiddenOptic && !m_ERRHiddenOptic.IsDeleted()) m_ERRHiddenOptic.SetFlags(EntityFlags.VISIBLE, false);
|
||||
m_ERRHiddenWeapon = null; m_ERRHiddenOptic = null;
|
||||
}
|
||||
void Close()
|
||||
{
|
||||
RestoreWeapon();
|
||||
m_Optic = null;
|
||||
if (m_wRoot) m_wRoot.RemoveFromHierarchy();
|
||||
m_wRoot = null; m_ERRScopeRoot = null;
|
||||
}
|
||||
void ~ERR_ObserverScopeDisplay() { Close(); }
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
// Corpses lose their player-controller association. The round roster, not the
|
||||
// current controller list, is the authority for which bodies belong to players.
|
||||
class ERR_ObserverTargets
|
||||
{
|
||||
static void Bodies(ERR_GameMode mode, array<IEntity> targets)
|
||||
{
|
||||
if (!mode || !mode.Players() || !mode.PlayerFaction()) return;
|
||||
array<ref ERR_PlayerLife> lives = {};
|
||||
mode.Players().Participants(lives);
|
||||
foreach (ERR_PlayerLife life : lives)
|
||||
{
|
||||
if (!life || !life.UsedLife || life.AccountId.IsNull() || !life.Actor || ERR_Actors.Alive(life.Actor)) continue;
|
||||
// Native death cleanup can drop affiliation as well as controller ownership.
|
||||
Faction faction = SCR_FactionManager.SGetFaction(life.Actor);
|
||||
if ((faction && faction != mode.PlayerFaction()) || targets.Contains(life.Actor)) continue;
|
||||
targets.Insert(life.Actor);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"addons/core/Configs/ERR/ObserverInput.conf": "e898d645911e85b6ba902366cb23890d8bb14bbd2c485102e6d49ea728e9b054",
|
||||
"addons/core/Configs/ERR/ObserverInput.conf.meta": "9714dce6c38ee81bcc513c8ebef51d7af63fd15bcdda2e40946937c625dc3ba4",
|
||||
"addons/core/Prefabs/ERR/SpectatorCamera.et": "761d845fc1c086da58fd87c8933bf58d46d1ff5e17cca5950141743d07c8ce16",
|
||||
"addons/core/Prefabs/ERR/SpectatorCamera.et.meta": "2a16558f93915e31908a674509e9c89976470f78a84a1174a64b94b68b8f77ad",
|
||||
"addons/core/Scripts/Game/ERR/00_Core/ERR_Observer.c": "8d05074992004297bc04a51f309760090d4ea1598a81468eaafb9b5dcd316a53",
|
||||
"addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverPlayerView.c": "040f26b36b54400d517fd5f470e532e3f9a53c96891c13e16e6c8e8e3ac6785a",
|
||||
"addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverScope.c": "3a4d20d1b42e40d6831b2ce2d85348f57a16a77c06fd936e32f1aa69f31cccfb",
|
||||
"addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverTargets.c": "1dfd50307b5d74075b90b5f2f70bae2c9488fc12df03d82eb19350a724f910f6",
|
||||
"tests/test_observer_head_visibility.py": "6b0c8eb989819bf2c0ed7a6c31e49f0d95358b4dd497d4508d1706e54c05495d",
|
||||
"tests/test_observer_ownership.py": "344433a5cef91e94c1de6860a714de872efebb1d3a361cfd8c8ac8c6fa902521",
|
||||
"tests/test_observer_scope.py": "f70d9dea673fb680c589fce8eb143380fd2ce407be602c04835b0e887904d0fb",
|
||||
"tests/test_observer_view_buffer.py": "0ff6c739f9e82880a7602bd3f47b98f0c6fd1956027ca26f494c3793fe154dbd",
|
||||
"tests/test_observer_view_relay.py": "0ef0ab00900650d21e8cda0d8b71d70ebb692abde1981d76ff9f624370852f11",
|
||||
"tests/test_spectator_recovery.py": "f820ec3c4ed588834fa3415c389c21a9d05489fc7bdb7b52c22086797b5166b8"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
"""Production local head-fade ownership and restoration using native doubles."""
|
||||
from policy_harness import ROOT,body,run
|
||||
p=ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverPlayerView.c'
|
||||
s='\n'.join(body(p,k) for k in ['void Update(IEntity target', 'void Restore()'])
|
||||
s=s.replace('null','nullptr').replace('SCR_ChimeraCharacter.Cast','SCR_ChimeraCharacter::Cast').replace('SCR_CharacterCameraHandlerComponent.Cast','SCR_CharacterCameraHandlerComponent::Cast').replace('m_Character.','m_Character->').replace('m_Handler.','m_Handler->').replace('GetCharacterController().','GetCharacterController()->')
|
||||
run(r'''
|
||||
#include <cassert>
|
||||
using vector=int;
|
||||
struct SCR_ChimeraCharacter;
|
||||
struct SCR_CharacterCameraHandlerComponent {SCR_ChimeraCharacter* character;int calls=0;static SCR_CharacterCameraHandlerComponent* Cast(SCR_CharacterCameraHandlerComponent* p){return p;}void UpdateHeadVisibility(int);void OnAlphatestChange(int);};
|
||||
struct SCR_ChimeraCharacter {int m_fFaceAlphaTest=0;bool animation=true;SCR_CharacterCameraHandlerComponent handler{this};static SCR_ChimeraCharacter* Cast(SCR_ChimeraCharacter* p){return p;}SCR_ChimeraCharacter* GetCharacterController(){return this;}SCR_CharacterCameraHandlerComponent* GetCameraHandlerComponent(){return &handler;}bool GetAnimation(){return animation;}};
|
||||
void SCR_CharacterCameraHandlerComponent::UpdateHeadVisibility(int value){character->m_fFaceAlphaTest=value;calls++;}
|
||||
void SCR_CharacterCameraHandlerComponent::OnAlphatestChange(int value){character->m_fFaceAlphaTest=value;calls++;}
|
||||
using IEntity=SCR_ChimeraCharacter*;
|
||||
struct Fade {SCR_ChimeraCharacter* m_Character=nullptr;SCR_CharacterCameraHandlerComponent* m_Handler=nullptr;int m_Previous=0,m_Applied=0;
|
||||
'''+s+r'''
|
||||
};
|
||||
int main(){
|
||||
SCR_ChimeraCharacter a,b;Fade f;a.m_fFaceAlphaTest=42;b.m_fFaceAlphaTest=10;
|
||||
f.Update(&a,255);assert(a.m_fFaceAlphaTest==255);f.Update(&a,230);f.Restore();assert(a.m_fFaceAlphaTest==42);
|
||||
int calls=a.handler.calls;f.Restore();assert(a.handler.calls==calls);
|
||||
f.Update(&a,255);f.Update(&b,255);assert(a.m_fFaceAlphaTest==42&&b.m_fFaceAlphaTest==255);
|
||||
b.m_fFaceAlphaTest=99;f.Restore();assert(b.m_fFaceAlphaTest==99); // later native camera owner wins
|
||||
f.Update(&a,255);f.Update(nullptr,0);assert(a.m_fFaceAlphaTest==42);
|
||||
a.animation=false;f.Update(&a,255);assert(a.m_fFaceAlphaTest==42);f.Restore();assert(a.m_fFaceAlphaTest==42);
|
||||
}
|
||||
''')
|
||||
print('PASS: native head/helmet fade is local, target-scoped, restored on exit/switch and does not overwrite a later camera owner')
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
"""Actual observer monitor/recovery/cleanup; native camera lifecycle is doubled."""
|
||||
import os,re
|
||||
from pathlib import Path
|
||||
from policy_harness import ROOT,body,run
|
||||
p=Path(os.environ.get('ERR_OBSERVER_SOURCE',ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_Observer.c'))
|
||||
game_mode=Path(os.environ.get('ERR_GAME_MODE_SOURCE',ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_GameMode.c'))
|
||||
binder=body(game_mode,'protected void BindLocalObserver(')
|
||||
binder=binder.replace('protected ', '').replace('SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController());','Controller* controller = static_cast<Controller*>(GetGame()->GetPlayerController());')
|
||||
binder=binder.replace('GetGame().','GetGame()->').replace('GetCallqueue().','GetCallqueue()->').replace('Remove(BindLocalObserver)','Remove(this)').replace('controller.','controller->')
|
||||
def cpp(s):
|
||||
s=s.replace('protected ','').replace('null','nullptr')
|
||||
for n in ('ERR_GameMode','ERR_Actors','ERR_ObserverRules','ERR_ObserverGuidance','SCR_HintManagerComponent','SCR_RespawnSystemComponent','SCR_EditorManagerEntity','Resource','SCR_ManualCamera','RplId','ETransformMode','LogLevel','System'):
|
||||
s=s.replace(n+'.',n+'::')
|
||||
for n in ('ERR_GameMode','SCR_HintManagerComponent','SCR_RespawnSystemComponent','SCR_EditorManagerEntity','Resource','CameraManager','CameraBase'):
|
||||
s=re.sub(r'\b'+n+r' (\w+)',n+r'* \1',s)
|
||||
for n in ('mode','hints','respawn','editor','resource','manager','m_ERRObserverCamera'):
|
||||
s=s.replace(n+'.',n+'->')
|
||||
s=s.replace('GetGame().','GetGame()->').replace('GetWorld().','GetWorld()->').replace('GetCallqueue().','GetCallqueue()->')
|
||||
s=s.replace('Remove(ERR_UpdateObserverClient)','Remove(this)').replace('CallLater(ERR_UpdateObserverClient, 250, true)','CallLater(this, 250, true)')
|
||||
s=s.replace('EntitySpawnParams params = new EntitySpawnParams();','EntitySpawnParams params;')
|
||||
return s
|
||||
methods='\n'.join(cpp(body(p,n)) for n in ('void ERR_StartObserverMonitor(', 'protected void ERR_UpdateObserverClient(', 'protected void ERR_UpdateRecoveryCamera(', 'protected void ERR_CloseObserverCamera('))
|
||||
run(r'''#include <cassert>
|
||||
#include <set>
|
||||
#include <string>
|
||||
struct string:std::string {using std::string::string;using std::string::operator=;string(std::string s):std::string(s){}bool IsEmpty()const{return empty();}};
|
||||
struct Actor {bool alive=true,conscious=true;};using IEntity=Actor*;
|
||||
struct ERR_Actors {static bool Alive(IEntity a){return a&&a->alive;}static bool CanFight(IEntity a){return Alive(a)&&a->conscious;}};
|
||||
struct ERR_GameMode {string RecoveryStatus(){return "";}bool active=true;static ERR_GameMode*current;static ERR_GameMode*Get(){return current;}bool Active(){return active;}} mode;
|
||||
ERR_GameMode*ERR_GameMode::current=&mode;
|
||||
struct ERR_ObserverRules {static bool View(int reason,bool active,bool alive,bool conscious){return active&&((reason==4&&alive&&!conscious)||(reason!=0&&!alive));}};
|
||||
struct ERR_ObserverGuidance {static bool Refresh(bool ready,int oldReason,int reason,bool oldAvailable,bool available){return !ready||oldReason!=reason||oldAvailable!=available;}static string Message(int,bool){return "spectating";}};
|
||||
struct Vec{Vec operator+(const char*)const{return {};}};
|
||||
struct EntitySpawnParams{int TransformMode=0;Vec Transform[4];};namespace ETransformMode{enum{WORLD};}
|
||||
struct Resource{static Resource*Load(const char*){static Resource r;return &r;}bool IsValid(){return true;}};
|
||||
struct CameraBase{virtual ~CameraBase()=default;};
|
||||
struct CameraManager{CameraBase*current=nullptr;int sets=0;CameraBase*CurrentCamera(){return current;}void SetCamera(CameraBase*c){current=c;sets++;}} manager;
|
||||
int terminated=0,deleted=0,explicitSwitch=0;
|
||||
struct SCR_ManualCamera:CameraBase{
|
||||
CameraBase*previous=nullptr;bool ended=false;
|
||||
static SCR_ManualCamera*Cast(SCR_ManualCamera*c){return c;}
|
||||
void TrySwitchToControlledEntityCamera(){explicitSwitch++;}
|
||||
void Terminate(){if(!ended){ended=true;terminated++;}}
|
||||
// SDK SCR_ManualCamera destructor invokes SwitchToPreviousCamera, which
|
||||
// changes only its own currently selected camera, never a foreign current one.
|
||||
~SCR_ManualCamera(){Terminate();if(manager.current==this&&previous)manager.SetCamera(previous);deleted++;}
|
||||
};
|
||||
struct SCR_HintManagerComponent{int attempts=0;static SCR_HintManagerComponent*GetInstance(){static SCR_HintManagerComponent h;return &h;}bool ShowCustom(string,string,int){attempts++;return true;}};
|
||||
struct SCR_RespawnSystemComponent{int clears=0;static SCR_RespawnSystemComponent*GetInstance(){static SCR_RespawnSystemComponent r;return &r;}void DestroyLoadingPlaceholder(){clears++;}};
|
||||
struct SCR_EditorManagerEntity{bool opened=false;int closes=0;static SCR_EditorManagerEntity*GetInstance(){static SCR_EditorManagerEntity e;return &e;}bool IsOpened(){return opened;}void Close(bool){opened=false;closes++;}};
|
||||
struct RplId{static int Invalid(){return -1;}};namespace LogLevel{enum{WARNING};}void Print(const char*,int){}
|
||||
namespace System{bool IsConsoleApp(){return false;}}
|
||||
struct Queue{std::set<void*>owners;int added=0;void CallLater(void*owner,int,bool){owners.insert(owner);added++;}void Remove(void*owner){owners.erase(owner);}} queue;
|
||||
struct World{float now=1;float GetWorldTime(){return now;}} world;
|
||||
struct Game{void*local=nullptr;int spawns=0;void*GetPlayerController(){return local;}Queue*GetCallqueue(){return &queue;}World*GetWorld(){return &world;}CameraManager*GetCameraManager(){return &manager;}
|
||||
SCR_ManualCamera*SpawnEntityPrefabLocal(Resource*,World*,EntitySpawnParams){spawns++;auto*c=new SCR_ManualCamera();c->previous=manager.current;manager.SetCamera(c);return c;}} game;
|
||||
bool gameAvailable=true;Game*GetGame(){return gameAvailable?&game:nullptr;}
|
||||
struct Controller{
|
||||
IEntity actor=nullptr,m_ERRRecoveryBody=nullptr;float m_ERRRecoveryDeadline=-1;
|
||||
bool m_ERRObserverMonitor=false,m_ERRObserverReady=false,m_ERRShownObserverAvailable=false;
|
||||
string m_ERRShownRecoveryStatus;
|
||||
int m_ERRObserverReason=0,m_ERRShownObserverReason=0,m_ERRObservedId=1,starts=0;
|
||||
Vec m_ERRObserverAnchor;SCR_ManualCamera*m_ERRObserverCamera=nullptr;CameraBase*playerCamera=nullptr;
|
||||
IEntity GetControlledEntity(){return actor;}CameraBase*GetPlayerCamera(){return playerCamera;}
|
||||
void ERR_OnObserverMonitorStarted(){starts++;}
|
||||
'''+methods+r'''
|
||||
};
|
||||
struct Binder {bool m_Stopped=false,m_NativeEndHandled=false;
|
||||
'''+binder+r'''
|
||||
};
|
||||
int main(){
|
||||
// The real repeating binder must survive the first controller and gaps.
|
||||
Binder binder;Controller first,replacement;queue.owners.insert(&binder);
|
||||
game.local=nullptr;binder.BindLocalObserver();assert(queue.owners.count(&binder)&&queue.added==0);
|
||||
game.local=&first;binder.BindLocalObserver();
|
||||
assert(first.m_ERRObserverMonitor&&first.starts==1&&queue.owners.count(&first));
|
||||
assert(queue.owners.count(&binder));
|
||||
binder.BindLocalObserver();assert(first.starts==1&&queue.added==1);
|
||||
game.local=nullptr;binder.BindLocalObserver();assert(queue.owners.count(&binder));
|
||||
game.local=&replacement;binder.BindLocalObserver();
|
||||
assert(replacement.m_ERRObserverMonitor&&replacement.starts==1&&queue.owners.count(&replacement));
|
||||
first.ERR_UpdateObserverClient();
|
||||
assert(!first.m_ERRObserverMonitor&&!queue.owners.count(&first)&&queue.owners.count(&replacement));
|
||||
binder.BindLocalObserver();assert(replacement.starts==1&&queue.added==2);
|
||||
binder.m_Stopped=true;binder.BindLocalObserver();assert(!queue.owners.count(&binder)&&queue.owners.count(&replacement));
|
||||
binder.m_Stopped=false;binder.m_NativeEndHandled=true;queue.owners.insert(&binder);binder.BindLocalObserver();
|
||||
assert(!queue.owners.count(&binder)&&queue.owners.count(&replacement)&&queue.added==2);
|
||||
queue.owners.clear();queue.added=0;game.local=nullptr;
|
||||
|
||||
Actor body;CameraBase formerBodyCamera,newBodyCamera;
|
||||
Controller former,current;former.actor=&body;former.playerCamera=&formerBodyCamera;
|
||||
game.local=&former;manager.current=&formerBodyCamera;former.ERR_StartObserverMonitor();
|
||||
body.conscious=false;former.m_ERRObserverReason=4;former.ERR_UpdateObserverClient();
|
||||
assert(former.m_ERRRecoveryBody==&body&&former.m_ERRObserverCamera&&queue.owners.count(&former));
|
||||
SCR_ManualCamera*oldOwned=former.m_ERRObserverCamera;
|
||||
// New controller owns a separate spectator camera; old body wakes meanwhile.
|
||||
current.playerCamera=&newBodyCamera;current.m_ERRObserverReason=1;game.local=¤t;current.ERR_StartObserverMonitor();current.ERR_UpdateObserverClient();
|
||||
SCR_ManualCamera*newOwned=current.m_ERRObserverCamera;assert(newOwned&&newOwned!=oldOwned&&manager.current==newOwned);
|
||||
auto*editor=SCR_EditorManagerEntity::GetInstance();editor->opened=true;body.conscious=true;
|
||||
int beforeClears=SCR_RespawnSystemComponent::GetInstance()->clears;
|
||||
int beforeSets=manager.sets,beforeHints=SCR_HintManagerComponent::GetInstance()->attempts,beforeSpawns=game.spawns,beforeDeleted=deleted;
|
||||
former.ERR_UpdateObserverClient();
|
||||
assert(manager.current==newOwned&&manager.sets==beforeSets&&editor->opened&&editor->closes==0);
|
||||
assert(!former.m_ERRRecoveryBody&&former.m_ERRRecoveryDeadline==-1&&!former.m_ERRObserverMonitor&&!former.m_ERRObserverCamera);
|
||||
assert(deleted==beforeDeleted+1&&terminated==deleted&&explicitSwitch==0);
|
||||
assert(current.m_ERRObserverCamera==newOwned&&queue.owners.count(¤t)==1&&queue.owners.count(&former)==0);
|
||||
assert(SCR_HintManagerComponent::GetInstance()->attempts==beforeHints&&game.spawns==beforeSpawns&&SCR_RespawnSystemComponent::GetInstance()->clears==beforeClears);
|
||||
// Even a queued stale spectator callback cannot create a camera or a hint.
|
||||
former.actor=nullptr;former.m_ERRObserverReason=1;former.ERR_UpdateObserverClient();
|
||||
assert(game.spawns==beforeSpawns&&manager.current==newOwned&&SCR_HintManagerComponent::GetInstance()->attempts==beforeHints);
|
||||
// Start is still local-only; reacquisition restarts exactly one owned timer.
|
||||
int added=queue.added;former.ERR_StartObserverMonitor();assert(queue.added==added);
|
||||
game.local=&former;former.actor=&body;former.m_ERRObserverReason=0;former.ERR_StartObserverMonitor();former.ERR_StartObserverMonitor();
|
||||
assert(queue.added==added+1&&former.starts==2&&queue.owners.count(¤t)==1);
|
||||
// Normal recovery still closes the editor and returns this controller's body.
|
||||
body.conscious=false;former.ERR_UpdateObserverClient();assert(former.m_ERRRecoveryBody==&body);
|
||||
body.conscious=true;former.ERR_UpdateObserverClient();assert(manager.current==&formerBodyCamera&&!editor->opened&&editor->closes==1&&!former.m_ERRRecoveryBody);
|
||||
// Foreign current camera survives this controller's retained camera cleanup.
|
||||
game.local=¤t;manager.current=&newBodyCamera;current.ERR_CloseObserverCamera();assert(manager.current==&newBodyCamera&&deleted==beforeDeleted+2);
|
||||
// If the old owned camera is still current at replacement, its native
|
||||
// destructor may restore its own previous camera; no new-controller camera is deleted.
|
||||
Controller abandoned;abandoned.m_ERRObserverReason=1;game.local=&abandoned;manager.current=&newBodyCamera;
|
||||
abandoned.ERR_StartObserverMonitor();abandoned.ERR_UpdateObserverClient();assert(manager.current==abandoned.m_ERRObserverCamera);
|
||||
game.local=¤t;beforeSets=manager.sets;beforeDeleted=deleted;abandoned.ERR_UpdateObserverClient();
|
||||
assert(!abandoned.m_ERRObserverCamera&&manager.current==&newBodyCamera&&manager.sets==beforeSets+1&&deleted==beforeDeleted+1);
|
||||
assert(queue.owners.count(&abandoned)==0&&queue.owners.count(¤t)==1);
|
||||
// Solo death may leave the controlled corpse assigned; restore the orbit
|
||||
// camera if the native death camera replaces it on a later tick.
|
||||
Controller solo;Actor corpse;corpse.alive=false;corpse.conscious=false;
|
||||
solo.actor=&corpse;solo.m_ERRObserverReason=1;game.local=&solo;editor->opened=false;
|
||||
solo.ERR_UpdateObserverClient();auto*corpseCamera=solo.m_ERRObserverCamera;
|
||||
assert(corpseCamera&&manager.current==corpseCamera);
|
||||
manager.current=&newBodyCamera;beforeClears=SCR_RespawnSystemComponent::GetInstance()->clears;beforeSpawns=game.spawns;
|
||||
solo.ERR_UpdateObserverClient();
|
||||
assert(manager.current==corpseCamera&&game.spawns==beforeSpawns&&SCR_RespawnSystemComponent::GetInstance()->clears==beforeClears+1);
|
||||
// Game Master remains usable during spectating, including before first camera creation.
|
||||
editor->opened=true;manager.current=&newBodyCamera;solo.ERR_UpdateObserverClient();
|
||||
assert(manager.current==&newBodyCamera&&game.spawns==beforeSpawns);
|
||||
solo.ERR_CloseObserverCamera();solo.ERR_UpdateObserverClient();
|
||||
assert(!solo.m_ERRObserverCamera&&manager.current==&newBodyCamera&&game.spawns==beforeSpawns);
|
||||
editor->opened=false;solo.ERR_UpdateObserverClient();assert(solo.m_ERRObserverCamera&&manager.current==solo.m_ERRObserverCamera);
|
||||
solo.ERR_CloseObserverCamera();
|
||||
gameAvailable=false;former.ERR_UpdateObserverClient();gameAvailable=true;
|
||||
}
|
||||
''')
|
||||
print('PASS: actual observer monitor/recovery/cleanup rejects retained old controller, preserves new camera/editor/hints/timer, restarts once and retains normal recovery; native effects modeled from SDK, no runtime claim')
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Actual scope sampling/identity selection; native weapon/camera APIs doubled."""
|
||||
from policy_harness import ROOT, body, run
|
||||
p=ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverScope.c'
|
||||
def adapt(s):
|
||||
import re
|
||||
s=s.replace('out vector view[4]', 'vector (&view)[4]').replace('out vector data','vector& data').replace('out float fov','float& fov').replace('out RplId weaponId','RplId& weaponId').replace('null','nullptr')
|
||||
for cls in ('ChimeraCharacter','CharacterControllerComponent','BaseWeaponManagerComponent','SCR_2DOpticsComponent','SCR_2DPIPSightsComponent','RplComponent'):
|
||||
s=re.sub(r'\b'+cls+r' (\w+)',cls+r'* \1',s);s=s.replace(cls+'.',cls+'::')
|
||||
for name in ('character','control','weapons','optic','rpl','pip','actor'):
|
||||
s=s.replace(name+'.',name+'->')
|
||||
for name in ('GetCharacterController()','GetCurrentWeapon()','GetOwner()','GetPIPCamera()'):
|
||||
s=s.replace(name+'.',name+'->')
|
||||
s=s.replace('FindComponent(RplComponent)','FindComponent()').replace('RplId.','RplId::').replace('Math.','Math::').replace('vector.Zero','vector::Zero')
|
||||
return s
|
||||
run(r'''
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
struct vector{float a=0,b=0,c=0;float&operator[](int i){return i==0?a:(i==1?b:c);}bool operator==(vector o){return a==o.a&&b==o.b&&c==o.c;}static vector Zero;};vector vector::Zero;
|
||||
vector Vector(float a,float b,float c){return {a,b,c};}
|
||||
struct RplId{int id=-1;RplId(int n=-1):id(n){}static RplId Invalid(){return {};}bool operator==(RplId o){return id==o.id;}bool operator!=(RplId o){return id!=o.id;}};
|
||||
namespace Math{float Round(float n){return std::round(n);}}
|
||||
struct RplComponent{RplId id=10;RplId Id(){return id;}static RplComponent*Cast(RplComponent*p){return p;}};
|
||||
struct Camera{void GetWorldCameraTransform(vector (&v)[4]){v[3]={1,2,3};}};
|
||||
struct SCR_2DOpticsComponent{bool ads=true,lit=false;float fov=12,offset=.1;virtual ~SCR_2DOpticsComponent(){}static SCR_2DOpticsComponent*Cast(SCR_2DOpticsComponent*p){return p;}bool IsSightADSActive(){return ads;}float GetFOV(){return fov;}float GetCurrentReticleOffsetY(){return offset;}bool ERR_ObserverIlluminated(){return lit;}};
|
||||
struct SCR_2DPIPSightsComponent:SCR_2DOpticsComponent{bool enabled=true;Camera*camera=nullptr;static SCR_2DPIPSightsComponent*Cast(SCR_2DOpticsComponent*p){return dynamic_cast<SCR_2DPIPSightsComponent*>(p);}bool IsPIPEnabled(){return enabled;}Camera*GetPIPCamera(){return camera;}};
|
||||
struct Weapon{RplComponent rpl;Weapon*GetOwner(){return this;}RplComponent*FindComponent(){return &rpl;}};
|
||||
struct BaseWeaponManagerComponent{Weapon*weapon=nullptr;SCR_2DOpticsComponent*optic=nullptr;int index=0;Weapon*GetCurrentWeapon(){return weapon;}SCR_2DOpticsComponent*GetCurrentSights(){return optic;}int GetCurrentSightsIndex(){return index;}};
|
||||
struct CharacterControllerComponent{bool ads=true;BaseWeaponManagerComponent weapons;bool IsWeaponADS(){return ads;}BaseWeaponManagerComponent*GetWeaponManagerComponent(){return &weapons;}};
|
||||
struct ChimeraCharacter{CharacterControllerComponent control;CharacterControllerComponent*GetCharacterController(){return &control;}static ChimeraCharacter*Cast(ChimeraCharacter*p){return p;}};using IEntity=ChimeraCharacter*;
|
||||
struct Scope{
|
||||
'''+''.join(adapt(body(p,s)) for s in ('static bool Valid(', 'static void Sample(', 'static SCR_2DOpticsComponent Resolve('))+r'''
|
||||
};
|
||||
int main(){
|
||||
ChimeraCharacter actor;Weapon weapon;SCR_2DOpticsComponent optic;actor.control.weapons.weapon=&weapon;actor.control.weapons.optic=&optic;
|
||||
vector view[4],data;float fov=60;RplId id;
|
||||
Scope::Sample(&actor,view,fov,id,data);assert(id==RplId(10)&&fov==12&&data[0]==1&&data[1]==.1f);
|
||||
assert(Scope::Resolve(&actor,id,data)==&optic);
|
||||
actor.control.weapons.index=1;assert(!Scope::Resolve(&actor,id,data));actor.control.weapons.index=0;
|
||||
assert(!Scope::Resolve(&actor,11,data));assert(!Scope::Resolve(nullptr,id,data));
|
||||
actor.control.ads=false;fov=60;Scope::Sample(&actor,view,fov,id,data);assert(id==RplId(-1)&&data==vector::Zero&&fov==60);actor.control.ads=true;
|
||||
optic.ads=false;Scope::Sample(&actor,view,fov,id,data);assert(id==RplId(-1));optic.ads=true;
|
||||
optic.fov=std::numeric_limits<float>::quiet_NaN();Scope::Sample(&actor,view,fov,id,data);assert(id==RplId(-1));optic.fov=12;
|
||||
SCR_2DPIPSightsComponent pip;actor.control.weapons.optic=&pip;
|
||||
Scope::Sample(&actor,view,fov,id,data);assert(id==RplId(-1)); // no camera yet
|
||||
Camera camera;pip.camera=&camera;pip.lit=true;Scope::Sample(&actor,view,fov,id,data);
|
||||
assert(id==RplId(10)&&data[2]==1&&view[3]==Vector(1,2,3)&&fov==12);
|
||||
pip.enabled=false;view[3]={7,8,9};Scope::Sample(&actor,view,fov,id,data);assert(view[3]==Vector(7,8,9)); // 2D keeps sampled player pose
|
||||
actor.control.weapons.weapon=nullptr;Scope::Sample(&actor,view,fov,id,data);assert(id==RplId(-1));
|
||||
}
|
||||
''')
|
||||
print('PASS: production scope sampling preserves ordinary views, selects active ADS only, carries zoom/zeroing/illumination and rejects mismatched/unstreamed optics; native APIs doubled')
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
"""Execute actual camera sample validation/interpolation; native math substituted."""
|
||||
from policy_harness import ROOT,body,run
|
||||
p=ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverPlayerView.c'
|
||||
def adapt(s):
|
||||
for name in ['ERR_ObserverScope','ERR_ObserverViewRules','Math','Math3D','vector','RplId']:s=s.replace(name+'.',name+'::')
|
||||
return s.replace('out vector','vector').replace('out float ','float& ')
|
||||
rules='\n'.join(adapt(body(p,k)) for k in ['static bool Sample(', 'static bool Fresh('])
|
||||
methods='\n'.join(adapt(body(p,k)) for k in ['void Clear(', 'void Push(', 'bool Read('])
|
||||
run(r'''
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
struct vector {float operator[](int i){return i==0?x:(i==1?y:z);}bool operator==(vector b){return x==b.x&&y==b.y&&z==b.z;}static vector Zero;float x=0,y=0,z=0;vector operator+(vector v){return{x+v.x,y+v.y,z+v.z};}vector operator-(vector v){return{x-v.x,y-v.y,z-v.z};}vector operator*(float n){return{x*n,y*n,z*n};}
|
||||
float LengthSq(){return x*x+y*y+z*z;}void Normalize(){float n=sqrt(LengthSq());x/=n;y/=n;z/=n;}
|
||||
static float Dot(vector a,vector b){return a.x*b.x+a.y*b.y+a.z*b.z;}static vector Lerp(vector a,vector b,float f){return a+(b-a)*f;}
|
||||
};
|
||||
vector vector::Zero;
|
||||
namespace Math {float Round(float f){return std::round(f);}float AbsFloat(float f){return fabs(f);}float Max(float a,float b){return std::max(a,b);}float Clamp(float n,float a,float b){return std::clamp(n,a,b);}float Lerp(float a,float b,float f){return a+(b-a)*f;}}
|
||||
vector cross(vector a,vector b){return{a.y*b.z-a.z*b.y,a.z*b.x-a.x*b.z,a.x*b.y-a.y*b.x};}
|
||||
namespace Math3D {void DirectionAndUpMatrix(vector forward,vector up,vector* out){auto right=cross(up,forward);right.Normalize();out[0]=right;out[1]=cross(forward,right);out[2]=forward;}}
|
||||
struct RplId {int value=-1;RplId(int v=-1):value(v){}static RplId Invalid(){return{};}bool operator==(RplId a){return value==a.value;}bool operator!=(RplId a){return value!=a.value;}};
|
||||
struct ERR_ObserverViewRules {'''+rules+r'''};
|
||||
struct ERR_ObserverScope {'''+adapt(body(ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverScope.c','static bool Valid('))+r'''};
|
||||
struct Buffer {RplId m_ScopeWeapon;vector m_ScopeData;RplId m_Target;int m_Stamp=-1;float m_Received=-1;vector m_Position,m_Forward,m_Up,m_StartPosition,m_StartForward,m_StartUp;float m_Fov=0,m_Near=0,m_StartFov=0,m_Blend=1;
|
||||
'''+methods+r'''};
|
||||
bool close(float a,float b){return fabs(a-b)<0.0001;}
|
||||
int main(){
|
||||
vector pos{.1,1.6,.2},forward{0,0,1},up{0,1,0};
|
||||
assert(ERR_ObserverViewRules::Sample(pos,forward,up,60,.0125));
|
||||
assert(!ERR_ObserverViewRules::Sample({31,0,0},forward,up,60,.0125));
|
||||
assert(!ERR_ObserverViewRules::Sample(pos,{},up,60,.0125));
|
||||
assert(!ERR_ObserverViewRules::Sample(pos,forward,forward,60,.0125));
|
||||
float nan=std::numeric_limits<float>::quiet_NaN();
|
||||
assert(!ERR_ObserverViewRules::Sample({nan,0,0},forward,up,60,.0125));
|
||||
assert(!ERR_ObserverViewRules::Sample(pos,{nan,0,1},up,60,.0125));
|
||||
assert(!ERR_ObserverViewRules::Sample(pos,forward,up,nan,.0125));
|
||||
assert(!ERR_ObserverViewRules::Sample(pos,forward,up,60,nan));
|
||||
assert(!ERR_ObserverViewRules::Sample(pos,forward,up,0,.0125));
|
||||
Buffer b;vector matrix[4];float fov,nearPlane;
|
||||
assert(!b.Read(1,100,1,matrix,fov,nearPlane));
|
||||
b.Push(1,100,100,pos,forward,up,60,.0125, -1, {});assert(b.Read(1,100,.01,matrix,fov,nearPlane));assert(close(matrix[3].y,1.6)&&close(fov,60));
|
||||
// Actual lean/roll and zoom, without forcing world-up or a hard-coded camera FOV.
|
||||
vector leanedUp{.6,.8,0};b.Push(1,150,150,{.3,1.4,.2},forward,leanedUp,30,.02, -1, {});
|
||||
assert(b.Read(1,175,.025,matrix,fov,nearPlane));assert(close(matrix[3].x,.2)&&close(fov,45)&&matrix[1].x>0);
|
||||
assert(b.Read(1,200,.025,matrix,fov,nearPlane));assert(close(matrix[3].x,.3)&&close(fov,30)&&close(matrix[1].x,.6)&&close(nearPlane,.02));
|
||||
b.Push(1,149,220,{9,9,9},forward,up,80,.1, -1, {}); // out-of-order packet cannot rewind the view
|
||||
assert(b.Read(1,225,.025,matrix,fov,nearPlane)&&close(matrix[3].x,.3));
|
||||
for(int i=0;i<1000;i++)assert(b.Read(1,250,.016,matrix,fov,nearPlane)&&close(matrix[3].x,.3)); // no integration drift
|
||||
assert(!b.Read(1,501,.01,matrix,fov,nearPlane)); // gap beyond 350 ms, no stale head approximation
|
||||
assert(!b.Read(2,250,.01,matrix,fov,nearPlane)); // target switch never inherits previous pose
|
||||
b.Push(2,200,600,{1,2,3},forward,up,75,.1, -1, {});assert(b.Read(2,600,0,matrix,fov,nearPlane)&&close(matrix[3].x,1));
|
||||
b.Clear();assert(!b.Read(2,600,0,matrix,fov,nearPlane));
|
||||
b.Push(1,700,700,pos,forward,up,12,.02,22,{1,0,1});
|
||||
assert(b.Read(1,700,0,matrix,fov,nearPlane)&&close(fov,12)&&b.m_ScopeWeapon==RplId(22));
|
||||
b.Push(1,701,710,pos,forward,up,60,.02,22,{nan,0,1});
|
||||
assert(b.m_Stamp==700&&b.m_ScopeData[0]==1);
|
||||
b.Push(1,702,720,pos,forward,up,60,.02,-1,{});
|
||||
assert(b.Read(1,720,0,matrix,fov,nearPlane)&&close(fov,60)&&b.m_ScopeWeapon==RplId(-1));
|
||||
assert(!ERR_ObserverScope::Valid(-1,{1,0,0}));
|
||||
assert(!ERR_ObserverScope::Valid(22,{1.5,0,0}));
|
||||
assert(!ERR_ObserverScope::Valid(22,{1,91,0}));
|
||||
assert(!ERR_ObserverScope::Valid(22,{1,0,2}));
|
||||
assert(!ERR_ObserverViewRules::Fresh(100,101));
|
||||
}
|
||||
''')
|
||||
print('PASS: actual view buffer validates samples, follows position/roll/FOV, rejects stale/foreign/reordered data, interpolates without drift and expires gaps')
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
"""Production relay authority/leases and delivery, with owner RPC/native data doubled."""
|
||||
import re
|
||||
from policy_harness import ROOT,body,run
|
||||
p=ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverPlayerView.c'
|
||||
def adapt(s):
|
||||
s=s.replace('protected ', '').replace('null','nullptr')
|
||||
for cls in ['ERR_GameMode','ERR_PlayerLife','SCR_PlayerController','RplComponent']:
|
||||
s=re.sub(r'\b'+cls+r' (\w+)\b',cls+r'* \1',s)
|
||||
for obj in ['mode','life','watcher','source','component']:s=s.replace(obj+'.',obj+'->')
|
||||
s=s.replace('m_ERRViewWatchers[i].','m_ERRViewWatchers[i]->')
|
||||
for cls in ['ERR_ObserverScope','ERR_GameMode','ERR_Actors','ERR_ObserverRules','ERR_ObserverViewRules','Replication','SCR_PlayerController','RplComponent','SCR_FactionManager','RplId','Math','vector']:s=s.replace(cls+'.',cls+'::')
|
||||
s=s.replace('GetGame().','GetGame()->').replace('GetWorld().','GetWorld()->').replace('GetPlayerManager().','GetPlayerManager()->').replace('Players().','Players()->').replace('Get().','Get()->')
|
||||
s=s.replace('actor.FindComponent(RplComponent)', 'actor->FindComponent()')
|
||||
s=s.replace('Rpc(ERR_EnableViewPublisher)', 'SendLease()')
|
||||
return s
|
||||
methods='\n'.join(adapt(body(p,k)) for k in ['bool ERR_ViewRecipient(', 'protected void ERR_RequestPlayerView(', 'void ERR_AddViewWatcher(', 'protected void ERR_EnableViewPublisher(', 'protected void ERR_SubmitPlayerView(', 'protected void ERR_ReceivePlayerView('])
|
||||
run(r'''
|
||||
#include <cassert>
|
||||
#include <vector>
|
||||
#include <map>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
struct vector {float operator[](int i){return i==0?x:(i==1?y:z);}bool operator==(vector b){return x==b.x&&y==b.y&&z==b.z;}static vector Zero;float x=0,y=0,z=0;float LengthSq(){return x*x+y*y+z*z;}static float Dot(vector a,vector b){return a.x*b.x+a.y*b.y+a.z*b.z;}};
|
||||
vector vector::Zero;
|
||||
namespace Math{float Round(float f){return std::round(f);}float AbsFloat(float a){return fabs(a);}}
|
||||
struct RplId{int value=-1;RplId(int i=-1):value(i){}static RplId Invalid(){return{-1};}bool operator==(RplId b){return value==b.value;}bool operator!=(RplId b){return value!=b.value;}};
|
||||
struct RplComponent{RplId id{11};RplId Id(){return id;}static RplComponent* Cast(RplComponent* p){return p;}};
|
||||
struct Actor {bool alive=true,conscious=true;int faction=1;RplComponent replication;RplComponent* FindComponent(){return &replication;}};using IEntity=Actor*;
|
||||
struct ERR_PlayerLife {int ConnectionId=1;bool valid=true;};
|
||||
struct Roster {std::map<IEntity,ERR_PlayerLife*> lives;ERR_PlayerLife* CurrentActor(IEntity a){auto p=lives.find(a);return p!=lives.end()&&p->second->valid?p->second:nullptr;}};
|
||||
struct ERR_GameMode {bool active=true;Roster roster;static ERR_GameMode* current;static ERR_GameMode* Get(){return current;}bool Active(){return active;}Roster* Players(){return &roster;}int PlayerFaction(){return 1;}};ERR_GameMode* ERR_GameMode::current;
|
||||
namespace ERR_Actors {bool Alive(IEntity a){return a&&a->alive;}bool CanFight(IEntity a){return Alive(a)&&a->conscious;}}
|
||||
namespace SCR_FactionManager{int SGetFaction(IEntity a){return a?a->faction:0;}}
|
||||
namespace ERR_ObserverReason{int NONE=0,UNCONSCIOUS=4;}
|
||||
struct ERR_ObserverRules {'''+body(ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_Observer.c','static bool View(').replace('ERR_ObserverReason.','ERR_ObserverReason::')+r'''};
|
||||
struct ERR_ObserverViewRules {'''+adapt(body(p,'static bool Sample('))+r'''};
|
||||
struct ERR_ObserverScope {'''+adapt(body(ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverScope.c','static bool Valid('))+r'''};
|
||||
namespace Replication{bool server=true;bool IsServer(){return server;}}
|
||||
struct SCR_PlayerController;
|
||||
struct Game {float now=1000;SCR_PlayerController* local=nullptr;std::map<int,SCR_PlayerController*> controllers;
|
||||
Game* GetWorld(){return this;}Game* GetPlayerManager(){return this;}float GetWorldTime(){return now;}SCR_PlayerController* GetPlayerController(){return local;}SCR_PlayerController* GetPlayerController(int id){return controllers[id];}} game;
|
||||
Game* GetGame(){return &game;}
|
||||
template<class T>struct Array:std::vector<T>{int Count(){return this->size();}bool Contains(T v){return std::find(this->begin(),this->end(),v)!=this->end();}void Insert(T v){this->push_back(v);}void Remove(int i){this->erase(this->begin()+i);}};
|
||||
struct Buffer{int pushes=0;void Push(RplId,int,float,vector,vector,vector,float,float,RplId,vector){pushes++;}};
|
||||
struct SCR_PlayerController {
|
||||
int id=0;IEntity controlled=nullptr,m_ERRServerObserved=nullptr,m_ERRViewSourceBody=nullptr;RplId m_ERRObservedId;
|
||||
int m_ERRObserverReason=1,m_ERRViewInputSequence=0,leases=0,deliveries=0;float m_ERRViewLeaseUntil=-1,m_ERRViewServerRequestAt=-1000,m_ERRViewLeaseSent=-1000,m_ERRViewPublishUntil=-1,m_ERRViewLastPacket=-1000;
|
||||
Array<SCR_PlayerController*> m_ERRViewWatchers;Buffer m_ERRViewBuffer;
|
||||
int GetPlayerId(){return id;}IEntity GetControlledEntity(){return controlled;}static SCR_PlayerController* Cast(SCR_PlayerController* p){return p;}
|
||||
void SendLease(){leases++;}
|
||||
void ERR_DeliverPlayerView(RplId,int,vector,vector,vector,float,float,RplId,vector){deliveries++;}
|
||||
'''+methods+r'''
|
||||
};
|
||||
int main(){
|
||||
ERR_GameMode mode;ERR_GameMode::current=&mode;Actor target,dead,revived;dead.alive=false;
|
||||
ERR_PlayerLife sourceLife;mode.roster.lives[&target]=&sourceLife;
|
||||
SCR_PlayerController source,watcher,stranger;source.id=1;watcher.id=2;stranger.id=3;
|
||||
source.controlled=⌖watcher.controlled=&dead;watcher.m_ERRServerObserved=⌖watcher.m_ERRObservedId={11};
|
||||
game.controllers={{1,&source},{2,&watcher},{3,&stranger}};
|
||||
assert(!watcher.ERR_ViewRecipient(&target,game.now)); // no lease
|
||||
watcher.ERR_RequestPlayerView();assert(source.m_ERRViewWatchers.Count()==1&&source.leases==1);
|
||||
watcher.ERR_RequestPlayerView();assert(source.leases==1); // bounded requests
|
||||
vector position{0,1.6,0},forward{0,0,1},up{0,1,0};
|
||||
source.ERR_SubmitPlayerView(1,position,forward,up,60,.0125, -1, {});assert(watcher.deliveries==1);
|
||||
source.ERR_SubmitPlayerView(2,position,forward,up,60,.0125, -1, {});assert(watcher.deliveries==1); // packet rate bound
|
||||
game.now+=50;source.ERR_SubmitPlayerView(1,position,forward,up,60,.0125, -1, {});assert(watcher.deliveries==1); // replay
|
||||
source.ERR_SubmitPlayerView(2,{999,0,0},forward,up,60,.0125, -1, {});assert(watcher.deliveries==1);
|
||||
source.ERR_SubmitPlayerView(2,position,forward,up,60,.0125, -1, {});assert(watcher.deliveries==2);
|
||||
game.now+=50;source.ERR_SubmitPlayerView(3,position,forward,up,12,.0125,22,{99,0,0});assert(watcher.deliveries==2&&source.m_ERRViewInputSequence==2); // malformed scope cannot advance sequence or deliver
|
||||
|
||||
game.now+=50;watcher.controlled=&revived;source.ERR_SubmitPlayerView(3,position,forward,up,60,.0125, -1, {});assert(watcher.deliveries==2&&source.m_ERRViewWatchers.Count()==0); // no stream to recovered players
|
||||
watcher.controlled=&dead;game.now+=500;watcher.ERR_RequestPlayerView();assert(source.m_ERRViewWatchers.Count()==1);
|
||||
target.faction=2;game.now+=50;source.ERR_SubmitPlayerView(4,position,forward,up,60,.0125, -1, {});assert(watcher.deliveries==2);target.faction=1;
|
||||
game.now+=500;watcher.ERR_RequestPlayerView();game.now+=1600;source.ERR_SubmitPlayerView(5,position,forward,up,60,.0125, -1, {});assert(watcher.deliveries==2&&source.m_ERRViewWatchers.Count()==0); // lease expired
|
||||
watcher.ERR_RequestPlayerView();watcher.m_ERRServerObserved=nullptr;game.now+=50;source.ERR_SubmitPlayerView(6,position,forward,up,60,.0125, -1, {});assert(watcher.deliveries==2); // target switch
|
||||
watcher.m_ERRServerObserved=⌖game.now+=500;watcher.ERR_RequestPlayerView();game.controllers[1]=&stranger;source.ERR_SubmitPlayerView(7,position,forward,up,60,.0125, -1, {});assert(watcher.deliveries==2);game.controllers[1]=&source;
|
||||
sourceLife.valid=false;game.now+=50;source.ERR_SubmitPlayerView(7,position,forward,up,60,.0125, -1, {});assert(watcher.deliveries==2);sourceLife.valid=true;
|
||||
game.local=&watcher;watcher.ERR_ReceivePlayerView({11},1,position,forward,up,60,.0125, -1, {});assert(watcher.m_ERRViewBuffer.pushes==1);
|
||||
watcher.ERR_ReceivePlayerView({22},2,position,forward,up,60,.0125, -1, {});assert(watcher.m_ERRViewBuffer.pushes==1);
|
||||
game.local=&stranger;watcher.ERR_ReceivePlayerView({11},3,position,forward,up,60,.0125, -1, {});assert(watcher.m_ERRViewBuffer.pushes==1);
|
||||
game.local=&watcher;mode.active=false;watcher.ERR_ReceivePlayerView({11},4,position,forward,up,60,.0125, -1, {});assert(watcher.m_ERRViewBuffer.pushes==1);
|
||||
mode.active=true;game.local=&source;source.ERR_EnableViewPublisher();assert(source.m_ERRViewPublishUntil==game.now+2000);
|
||||
}
|
||||
''')
|
||||
print('PASS: actual relay rejects noncanonical sources, live/foreign/stale viewers, invalid/replayed/fast packets; bounds leases and owner-only target delivery')
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
"""Execute production input dispatch and recovery with native camera/editor doubles."""
|
||||
from policy_harness import ROOT,body,run
|
||||
p=ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_Observer.c'
|
||||
s=body(p,'protected void ERR_UpdateRecoveryCamera(').replace('protected ','').replace('ERR_GameMode mode','ERR_GameMode* mode').replace('null','nullptr')
|
||||
for name in ('ERR_Actors','SCR_EditorManagerEntity','LogLevel'):
|
||||
s=s.replace(name+'.',name+'::')
|
||||
for name in ('SCR_EditorManagerEntity','CameraManager','CameraBase'):
|
||||
s=s.replace(name+' ',name+'* ')
|
||||
for name in ('mode','editor','manager'):
|
||||
s=s.replace(name+'.',name+'->')
|
||||
s=s.replace('GetGame().','GetGame()->').replace('GetWorld().','GetWorld()->')
|
||||
run(r'''#include <cassert>
|
||||
struct Actor {bool alive=true,conscious=true;};using IEntity=Actor*;
|
||||
struct ERR_Actors {static bool Alive(IEntity a){return a&&a->alive;}static bool CanFight(IEntity a){return Alive(a)&&a->conscious;}};
|
||||
struct ERR_GameMode {bool active=true;bool Active(){return active;}};
|
||||
struct SCR_EditorManagerEntity {bool opened=false,canClose=true;int closes=0;static SCR_EditorManagerEntity* current;static SCR_EditorManagerEntity* GetInstance(){return current;}bool IsOpened(){return opened;}void Close(bool){closes++;if(canClose)opened=false;}} editor;
|
||||
SCR_EditorManagerEntity* SCR_EditorManagerEntity::current=&editor;
|
||||
struct CameraBase{} player,other;
|
||||
struct CameraManager {CameraBase* current=&other;int sets=0;void SetCamera(CameraBase* c){current=c;sets++;}CameraBase* CurrentCamera(){return current;}} manager;
|
||||
struct World {float now=0;float GetWorldTime(){return now;}} world;
|
||||
struct Game {World* GetWorld(){return &world;}CameraManager* GetCameraManager(){return &manager;}} game;
|
||||
Game* GetGame(){return &game;}namespace LogLevel {enum{WARNING};}int warnings=0;void Print(const char*,int){warnings++;}
|
||||
struct Controller {
|
||||
IEntity controlled=nullptr,m_ERRRecoveryBody=nullptr;float m_ERRRecoveryDeadline=-1;CameraBase* camera=&player;
|
||||
IEntity GetControlledEntity(){return controlled;}CameraBase* GetPlayerCamera(){return camera;}
|
||||
'''+s+r'''
|
||||
};
|
||||
int main(){
|
||||
ERR_GameMode mode;Actor body,replacement;Controller c;c.controlled=&body;
|
||||
// Healthy admins retain their camera: restoration belongs only to recovery.
|
||||
c.ERR_UpdateRecoveryCamera(&mode);assert(manager.sets==0&&editor.closes==0);
|
||||
body.conscious=false;c.ERR_UpdateRecoveryCamera(&mode);assert(c.m_ERRRecoveryBody==&body&&manager.sets==0);
|
||||
// Recovery works with NO observer-camera instance and a different active camera.
|
||||
editor.opened=true;body.conscious=true;c.ERR_UpdateRecoveryCamera(&mode);
|
||||
assert(editor.closes==1&&!editor.opened&&manager.current==&player&&!c.m_ERRRecoveryBody);
|
||||
manager.current=&other;c.ERR_UpdateRecoveryCamera(&mode);assert(manager.current==&other); // no permanent camera lock
|
||||
// An asynchronously closing editor retains the pending recovery; next tick completes.
|
||||
body.conscious=false;c.ERR_UpdateRecoveryCamera(&mode);body.conscious=true;editor.opened=true;editor.canClose=false;
|
||||
c.ERR_UpdateRecoveryCamera(&mode);assert(c.m_ERRRecoveryBody==&body);
|
||||
editor.canClose=true;c.ERR_UpdateRecoveryCamera(&mode);assert(!c.m_ERRRecoveryBody&&manager.current==&player);
|
||||
// Wait for late camera creation, then return once.
|
||||
body.conscious=false;c.ERR_UpdateRecoveryCamera(&mode);body.conscious=true;c.camera=nullptr;
|
||||
c.ERR_UpdateRecoveryCamera(&mode);assert(c.m_ERRRecoveryBody==&body);
|
||||
c.camera=&player;c.ERR_UpdateRecoveryCamera(&mode);assert(!c.m_ERRRecoveryBody);
|
||||
// Refused editor close is bounded, warning instead of permanently stealing input.
|
||||
body.conscious=false;c.ERR_UpdateRecoveryCamera(&mode);body.conscious=true;editor.opened=true;editor.canClose=false;
|
||||
c.ERR_UpdateRecoveryCamera(&mode);world.now=10001;c.ERR_UpdateRecoveryCamera(&mode);assert(!c.m_ERRRecoveryBody&&warnings==1);
|
||||
editor.opened=false;editor.canClose=true;
|
||||
// Changed possession / stopped round must not return a stale casualty's camera.
|
||||
body.conscious=false;c.ERR_UpdateRecoveryCamera(&mode);c.controlled=&replacement;int before=manager.sets;
|
||||
c.ERR_UpdateRecoveryCamera(&mode);assert(!c.m_ERRRecoveryBody&&manager.sets==before);
|
||||
c.controlled=&body;c.ERR_UpdateRecoveryCamera(&mode);mode.active=false;body.conscious=true;
|
||||
c.ERR_UpdateRecoveryCamera(&mode);assert(!c.m_ERRRecoveryBody&&manager.sets==before);
|
||||
}
|
||||
''')
|
||||
input_body=body(p,'void Input(').replace('SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController());','SCR_PlayerController* controller = &pc;').replace('controller.','controller->').replace('ERR_ObserverRules.','ERR_ObserverRules::')
|
||||
run(r'''#include <cassert>
|
||||
struct ERR_ObserverRules {
|
||||
'''+body(p,'static int Cycle(')+body(p,'static bool Toggle(')+r'''
|
||||
};
|
||||
struct SCR_PlayerController {int calls=0,direction=0;void ERR_CycleObserver(int d){calls++;direction=d;}} pc;
|
||||
struct Follow {bool m_LeftDown=false,m_RightDown=false,m_EnterDown=false,m_PlayerView=false;int m_InputResumeFrames=0;
|
||||
'''+input_body+r'''
|
||||
};
|
||||
int main(){
|
||||
Follow f;f.Input(true,true,false,false);assert(pc.calls==1&&pc.direction==-1);
|
||||
f.Input(true,true,false,false);assert(pc.calls==1);
|
||||
f.Input(true,false,false,false);f.Input(true,false,true,false);assert(pc.calls==2&&pc.direction==1);
|
||||
f.Input(true,true,true,false);assert(pc.calls==2);
|
||||
f.Input(false,false,false,true);f.Input(true,false,false,true);assert(!f.m_PlayerView); // menu-held Enter
|
||||
f.Input(true,false,false,false);f.Input(true,false,false,true);assert(f.m_PlayerView);
|
||||
f.Input(true,false,false,true);assert(f.m_PlayerView);
|
||||
f.Input(true,false,false,false);f.Input(true,false,false,true);assert(!f.m_PlayerView);
|
||||
// Inactive chat context masks held keys as zero; submission must not become
|
||||
// a new camera press when the context resumes (including its first zero frame).
|
||||
f.Input(false,false,false,false);
|
||||
f.Input(true,false,false,false);
|
||||
f.Input(true,false,false,true);assert(!f.m_PlayerView);
|
||||
f.Input(true,false,false,true);assert(!f.m_PlayerView);
|
||||
f.Input(true,false,false,false);
|
||||
f.Input(true,false,false,true);assert(f.m_PlayerView);
|
||||
int before=pc.calls;
|
||||
f.Input(false,false,false,false);
|
||||
f.Input(true,false,false,false);
|
||||
f.Input(true,true,false,false);f.Input(true,true,false,false);assert(pc.calls==before);
|
||||
f.Input(true,false,false,false);f.Input(true,true,false,false);assert(pc.calls==before+1);
|
||||
}
|
||||
''')
|
||||
# Execute the actual pre-activation gate with UI/native state doubled.
|
||||
gate=body(p,'override protected bool IsDisabledByMenu(')
|
||||
gate=gate.replace('override protected ', '').replace('super.', 'Base::')
|
||||
gate=gate.replace('GetGame().', 'GetGame()->').replace('GetInputManager().', 'GetInputManager()->').replace('GetWorkspace().', 'GetWorkspace()->')
|
||||
gate=gate.replace('Widget focused', 'Widget* focused').replace('EditBoxWidget.Cast', 'EditBoxWidget::Cast').replace('focused.GetParent()', 'focused->GetParent()')
|
||||
run(r'''#include <cassert>
|
||||
struct Widget {Widget*parent=nullptr;bool edit=false;Widget*GetParent(){return parent;}};
|
||||
struct EditBoxWidget {static Widget*Cast(Widget*w){return w&&w->edit?w:nullptr;}};
|
||||
struct Inputs {bool chat=false;bool IsContextActive(const char*){return chat;}} inputs;
|
||||
struct Workspace {Widget*focus=nullptr;Widget*GetFocusedWidget(){return focus;}} workspace;
|
||||
struct Game {Inputs*GetInputManager(){return &inputs;}Workspace*GetWorkspace(){return &workspace;}} game;
|
||||
Game*GetGame(){return &game;}
|
||||
struct Base {bool menu=false;bool IsDisabledByMenu(){return menu;}};
|
||||
struct Camera:Base {
|
||||
'''+gate+r'''
|
||||
};
|
||||
int main(){
|
||||
Camera c;assert(!c.IsDisabledByMenu());
|
||||
c.menu=true;assert(c.IsDisabledByMenu());c.menu=false;
|
||||
inputs.chat=true;assert(c.IsDisabledByMenu());inputs.chat=false;
|
||||
Widget edit,child,ordinary;edit.edit=true;child.parent=&edit;
|
||||
workspace.focus=&edit;assert(c.IsDisabledByMenu());
|
||||
workspace.focus=&child;assert(c.IsDisabledByMenu());
|
||||
workspace.focus=&ordinary;assert(!c.IsDisabledByMenu());
|
||||
workspace.focus=nullptr;assert(!c.IsDisabledByMenu());
|
||||
}
|
||||
''')
|
||||
assert (ROOT/'addons/core/Prefabs/ERR/SpectatorCamera.et').read_text().startswith('ERR_ObserverCamera {')
|
||||
config=(ROOT/'addons/core/Configs/ERR/ObserverInput.conf').read_text()
|
||||
for key in ('KC_LEFT','KC_RIGHT','KC_RETURN','KC_NUMPADENTER'): assert f'keyboard:{key}' in config
|
||||
assert 'chimeraInputCommon.conf' in config and 'ActionContext/ManualCameraContext.conf' in config
|
||||
assert 'Debug.KeyState' not in p.read_text()
|
||||
print('PASS: actual input dispatch, one press per edge, menu-held suppression; actual revival camera handoff with editor close/retry, missing camera, replacement body and stopped round')
|
||||
@@ -0,0 +1,3 @@
|
||||
14:30:31.135 SCRIPT : Module: WorkbenchGame; loaded 186x files; 657x classes; used 2754K of static memory; defines: "ENF_WB,WORKBENCH,PLATFORM_WINDOWS,ENABLE_DIAG,ENF_ENABLE_VHC_OVERRIDE,ENF_ENABLE_RPLCOMPONENT_IN_GAMECODE"; CRC32: 5613aa11
|
||||
14:30:31.143 SCRIPT : Module: Game; loaded 6732x files; 13858x classes; used 42546K of static memory; defines: "ENF_WB,WORKBENCH,PLATFORM_WINDOWS,ENABLE_DIAG,ENF_ENABLE_VHC_OVERRIDE,ENF_ENABLE_RPLCOMPONENT_IN_GAMECODE,ENABLE_BASE_DESTRUCTION,BUDGET_OPTIMIZATION_CHECKS,SHOW_DISCLAIMER,DEBUG,RESPAWN_TIMER_COMPONENT_DEBUG,GAME_MODE_DEBUG,DEBUG_CONFIGURABLE_DIALOGS,DISABLE_WEAPON_SWITCHING,SCRIPTED_AIM_MODIFIER_DEBUG,WCS_DEFINES_ARMAMENTS,WCS_DEFINES_SPACECORE"; CRC32: fe2d3099
|
||||
14:30:31.209 INIT : Creating game instance(ArmaReforgerScripted), version 1.8.0.13 built 2026-08-21 14:30:18 UTC.
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"baseline": "Exact user-reported almost-perfect camera implementation, including Enter/chat fix. 14 files copied with SHA-256 before edits; all saved hashes verified.",
|
||||
"changed_files": [
|
||||
"addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverPlayerView.c",
|
||||
"tests/test_observer_view_buffer.py"
|
||||
],
|
||||
"change": "Replace packet-arrival-driven 50 ms blends with a bounded 12-pose timestamp buffer starting 100 ms behind first sample. Local elapsed-time playback, no extrapolation, 350 ms stale fallback, immediate fresh target/scope acquisition. Relay format, source sampling, body-local transform, scope logic and input remain unchanged.",
|
||||
"checks": {
|
||||
"buffer_regression": "passed: validation, position/roll/FOV, reorder rejection, expiry, target/scope switch, bounded unrendered stream, repeated reads, 30/60/144 FPS jitter/loss comparison",
|
||||
"synthetic_step_variation": [
|
||||
{
|
||||
"fps": 30,
|
||||
"buffered": 0.0336441,
|
||||
"baseline": 1.32469
|
||||
},
|
||||
{
|
||||
"fps": 60,
|
||||
"buffered": 0.0660537,
|
||||
"baseline": 0.822226
|
||||
},
|
||||
{
|
||||
"fps": 144,
|
||||
"buffered": 0.0253502,
|
||||
"baseline": 0.427864
|
||||
}
|
||||
],
|
||||
"other_passed": [
|
||||
"test_observer_view_relay.py",
|
||||
"test_observer_scope.py",
|
||||
"test_observer_head_visibility.py",
|
||||
"test_spectator_recovery.py"
|
||||
],
|
||||
"native_workbench": {
|
||||
"result": "compiled",
|
||||
"cleanup": {
|
||||
"cleanup_confirmed": true,
|
||||
"idle_verified": true,
|
||||
"errors": [],
|
||||
"orphan_cleanup": {
|
||||
"attempted": true,
|
||||
"report": {
|
||||
"scope": "/tmp/erri-headless-compat/pfx",
|
||||
"candidates": [],
|
||||
"term_sent": [],
|
||||
"kill_sent": [],
|
||||
"cleared": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"limitations": [
|
||||
"Synthetic interpolation comparisons execute production method bodies adapted to C++ with native math doubled. They are not multiplayer visual measurements.",
|
||||
"Hidden Workbench compilation passed; real multiplayer smoothness and synchronization with independently replicated character/weapon animations remain unverified.",
|
||||
"The new queue adds approximately 100 ms presentation delay; body tracking and network relay are unchanged."
|
||||
],
|
||||
"deployment": "Source only. No publishing, live-server changes or restart.",
|
||||
"rollback": "Restore changed_files from baseline/ after checking for subsequent edits. The saved smoothing.patch records exactly this change; do not reset the entire working tree."
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
--- a/addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverPlayerView.c
|
||||
+++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverPlayerView.c
|
||||
@@ -11,48 +11,75 @@
|
||||
}
|
||||
static bool Fresh(float now, float received) { return received >= 0 && now >= received && now - received <= 350; }
|
||||
}
|
||||
+// Keep actual sampled poses on a short timeline instead of restarting a blend
|
||||
+// on every packet arrival. Only presentation is delayed; possession is unchanged.
|
||||
+class ERR_ObserverViewSample
|
||||
+{
|
||||
+ int Stamp;
|
||||
+ vector Position, Forward, Up;
|
||||
+ float Fov, Near;
|
||||
+}
|
||||
class ERR_ObserverViewBuffer
|
||||
{
|
||||
protected RplId m_Target = RplId.Invalid();
|
||||
protected int m_Stamp = -1;
|
||||
protected RplId m_ScopeWeapon = RplId.Invalid();
|
||||
protected vector m_ScopeData;
|
||||
- void Scope(out RplId weapon, out vector data) { weapon = m_ScopeWeapon; data = m_ScopeData; }
|
||||
protected float m_Received = -1;
|
||||
- protected vector m_Position, m_Forward, m_Up;
|
||||
- protected vector m_StartPosition, m_StartForward, m_StartUp;
|
||||
- protected float m_Fov, m_Near, m_StartFov, m_Blend = 1;
|
||||
- void Clear() { m_Target = RplId.Invalid(); m_Stamp = -1; m_Received = -1; }
|
||||
+ protected float m_Playback, m_ReadAt;
|
||||
+ protected ref array<ref ERR_ObserverViewSample> m_Samples = {};
|
||||
+ void Scope(out RplId weapon, out vector data) { weapon = m_ScopeWeapon; data = m_ScopeData; }
|
||||
+ void Clear()
|
||||
+ {
|
||||
+ m_Target = RplId.Invalid(); m_Stamp = -1; m_Received = -1;
|
||||
+ m_ScopeWeapon = RplId.Invalid(); m_ScopeData = vector.Zero;
|
||||
+ m_Samples.Clear();
|
||||
+ }
|
||||
void Push(RplId target, int stamp, float now, vector position, vector forward, vector up, float fov, float nearPlane, RplId scopeWeapon, vector scopeData)
|
||||
{
|
||||
if (!ERR_ObserverScope.Valid(scopeWeapon, scopeData)) return;
|
||||
if (!ERR_ObserverViewRules.Sample(position, forward, up, fov, nearPlane)) return;
|
||||
if (target == m_Target && stamp <= m_Stamp) return;
|
||||
if (target != m_Target || scopeWeapon != m_ScopeWeapon || scopeData[0] != m_ScopeData[0] || !ERR_ObserverViewRules.Fresh(now, m_Received))
|
||||
- { m_StartPosition = position; m_StartForward = forward; m_StartUp = up; m_StartFov = fov; }
|
||||
- else
|
||||
{
|
||||
- m_StartPosition = vector.Lerp(m_StartPosition, m_Position, m_Blend);
|
||||
- m_StartForward = vector.Lerp(m_StartForward, m_Forward, m_Blend);
|
||||
- m_StartUp = vector.Lerp(m_StartUp, m_Up, m_Blend);
|
||||
- m_StartFov = Math.Lerp(m_StartFov, m_Fov, m_Blend);
|
||||
+ m_Samples.Clear();
|
||||
+ m_Playback = stamp - 100; m_ReadAt = now;
|
||||
}
|
||||
+ ERR_ObserverViewSample sample = new ERR_ObserverViewSample();
|
||||
+ sample.Stamp = stamp; sample.Position = position; sample.Forward = forward; sample.Up = up;
|
||||
+ sample.Fov = fov; sample.Near = nearPlane;
|
||||
+ m_Samples.Insert(sample);
|
||||
+ // Bounded even when first-person view is not being rendered.
|
||||
+ while (m_Samples.Count() > 12) m_Samples.Remove(0);
|
||||
m_ScopeWeapon = scopeWeapon; m_ScopeData = scopeData;
|
||||
- m_Target = target; m_Stamp = stamp; m_Received = now; m_Blend = 0;
|
||||
- m_Position = position; m_Forward = forward; m_Up = up; m_Fov = fov; m_Near = nearPlane;
|
||||
+ m_Target = target; m_Stamp = stamp; m_Received = now;
|
||||
}
|
||||
bool Read(RplId target, float now, float seconds, out vector local[4], out float fov, out float nearPlane)
|
||||
{
|
||||
- if (target != m_Target || !ERR_ObserverViewRules.Fresh(now, m_Received)) return false;
|
||||
- m_Blend = Math.Clamp(m_Blend + Math.Max(0, seconds) / 0.05, 0, 1);
|
||||
- vector forward = vector.Lerp(m_StartForward, m_Forward, m_Blend);
|
||||
- vector up = vector.Lerp(m_StartUp, m_Up, m_Blend);
|
||||
- if (forward.LengthSq() < 0.1 || up.LengthSq() < 0.1) { forward = m_Forward; up = m_Up; }
|
||||
+ if (target != m_Target || !ERR_ObserverViewRules.Fresh(now, m_Received) || m_Samples.IsEmpty()) return false;
|
||||
+ // Use elapsed local time, not the number of reads or a packet's arrival time.
|
||||
+ // No clock synchronization between clients is required: stamps are relative.
|
||||
+ if (now - m_ReadAt > 250) m_Playback = m_Stamp - 100;
|
||||
+ else m_Playback += Math.Max(0, now - m_ReadAt);
|
||||
+ m_ReadAt = now;
|
||||
+ // A long render stall resumes near live rather than replaying old movement.
|
||||
+ if (m_Stamp - m_Playback > 250) m_Playback = m_Stamp - 100;
|
||||
+ // Never extrapolate through walls or invent aim during a packet gap.
|
||||
+ m_Playback = Math.Min(m_Playback, m_Stamp);
|
||||
+ while (m_Samples.Count() > 2 && m_Samples[1].Stamp <= m_Playback) m_Samples.Remove(0);
|
||||
+ ERR_ObserverViewSample start = m_Samples[0];
|
||||
+ ERR_ObserverViewSample end = start;
|
||||
+ if (m_Samples.Count() > 1) end = m_Samples[1];
|
||||
+ float blend = 0;
|
||||
+ if (end.Stamp > start.Stamp) blend = Math.Clamp((m_Playback - start.Stamp) / (end.Stamp - start.Stamp), 0, 1);
|
||||
+ vector forward = vector.Lerp(start.Forward, end.Forward, blend);
|
||||
+ vector up = vector.Lerp(start.Up, end.Up, blend);
|
||||
+ if (forward.LengthSq() < 0.1 || up.LengthSq() < 0.1) { forward = end.Forward; up = end.Up; }
|
||||
forward.Normalize(); up.Normalize();
|
||||
- if (Math.AbsFloat(vector.Dot(forward, up)) > 0.95) { forward = m_Forward; up = m_Up; }
|
||||
+ if (Math.AbsFloat(vector.Dot(forward, up)) > 0.95) { forward = end.Forward; up = end.Up; }
|
||||
Math3D.DirectionAndUpMatrix(forward, up, local);
|
||||
- local[3] = vector.Lerp(m_StartPosition, m_Position, m_Blend);
|
||||
- fov = Math.Lerp(m_StartFov, m_Fov, m_Blend); nearPlane = m_Near;
|
||||
+ local[3] = vector.Lerp(start.Position, end.Position, blend);
|
||||
+ fov = Math.Lerp(start.Fov, end.Fov, blend); nearPlane = end.Near;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
--- a/tests/test_observer_view_buffer.py
|
||||
+++ b/tests/test_observer_view_buffer.py
|
||||
@@ -1,29 +1,41 @@
|
||||
"""Execute actual camera sample validation/interpolation; native math substituted."""
|
||||
+import re
|
||||
from policy_harness import ROOT,body,run
|
||||
p=ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverPlayerView.c'
|
||||
def adapt(s):
|
||||
for name in ['ERR_ObserverScope','ERR_ObserverViewRules','Math','Math3D','vector','RplId']:s=s.replace(name+'.',name+'::')
|
||||
- return s.replace('out vector','vector').replace('out float ','float& ')
|
||||
+ s=s.replace('out vector','vector').replace('out float ','float& ')
|
||||
+ s=re.sub(r'ERR_ObserverViewSample (\w+)', r'ERR_ObserverViewSample* \1',s)
|
||||
+ s=re.sub(r'(sample|start|end)\.', r'\1->',s)
|
||||
+ return re.sub(r'(m_Samples\[\d+\])\.',r'\1->',s)
|
||||
rules='\n'.join(adapt(body(p,k)) for k in ['static bool Sample(', 'static bool Fresh('])
|
||||
methods='\n'.join(adapt(body(p,k)) for k in ['void Clear(', 'void Push(', 'bool Read('])
|
||||
+baseline=ROOT/'docs/evidence/observer-smoothing-2026-10-09/baseline/addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverPlayerView.c'
|
||||
+old_methods='\n'.join(adapt(body(baseline,k)) for k in ['void Clear(', 'void Push(', 'bool Read('])
|
||||
run(r'''
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
+#include <vector>
|
||||
+#include <iostream>
|
||||
struct vector {float operator[](int i){return i==0?x:(i==1?y:z);}bool operator==(vector b){return x==b.x&&y==b.y&&z==b.z;}static vector Zero;float x=0,y=0,z=0;vector operator+(vector v){return{x+v.x,y+v.y,z+v.z};}vector operator-(vector v){return{x-v.x,y-v.y,z-v.z};}vector operator*(float n){return{x*n,y*n,z*n};}
|
||||
float LengthSq(){return x*x+y*y+z*z;}void Normalize(){float n=sqrt(LengthSq());x/=n;y/=n;z/=n;}
|
||||
static float Dot(vector a,vector b){return a.x*b.x+a.y*b.y+a.z*b.z;}static vector Lerp(vector a,vector b,float f){return a+(b-a)*f;}
|
||||
};
|
||||
vector vector::Zero;
|
||||
-namespace Math {float Round(float f){return std::round(f);}float AbsFloat(float f){return fabs(f);}float Max(float a,float b){return std::max(a,b);}float Clamp(float n,float a,float b){return std::clamp(n,a,b);}float Lerp(float a,float b,float f){return a+(b-a)*f;}}
|
||||
+namespace Math {float Round(float f){return std::round(f);}float AbsFloat(float f){return fabs(f);}float Max(float a,float b){return std::max(a,b);}float Min(float a,float b){return std::min(a,b);}float Clamp(float n,float a,float b){return std::clamp(n,a,b);}float Lerp(float a,float b,float f){return a+(b-a)*f;}}
|
||||
vector cross(vector a,vector b){return{a.y*b.z-a.z*b.y,a.z*b.x-a.x*b.z,a.x*b.y-a.y*b.x};}
|
||||
namespace Math3D {void DirectionAndUpMatrix(vector forward,vector up,vector* out){auto right=cross(up,forward);right.Normalize();out[0]=right;out[1]=cross(forward,right);out[2]=forward;}}
|
||||
struct RplId {int value=-1;RplId(int v=-1):value(v){}static RplId Invalid(){return{};}bool operator==(RplId a){return value==a.value;}bool operator!=(RplId a){return value!=a.value;}};
|
||||
struct ERR_ObserverViewRules {'''+rules+r'''};
|
||||
struct ERR_ObserverScope {'''+adapt(body(ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverScope.c','static bool Valid('))+r'''};
|
||||
-struct Buffer {RplId m_ScopeWeapon;vector m_ScopeData;RplId m_Target;int m_Stamp=-1;float m_Received=-1;vector m_Position,m_Forward,m_Up,m_StartPosition,m_StartForward,m_StartUp;float m_Fov=0,m_Near=0,m_StartFov=0,m_Blend=1;
|
||||
+struct ERR_ObserverViewSample {int Stamp;vector Position,Forward,Up;float Fov,Near;};
|
||||
+template<class T>struct Array:std::vector<T>{int Count(){return this->size();}bool IsEmpty(){return this->empty();}void Clear(){this->clear();}void Insert(T v){this->push_back(v);}void Remove(int i){this->erase(this->begin()+i);}};
|
||||
+struct Buffer {RplId m_ScopeWeapon;vector m_ScopeData;RplId m_Target;int m_Stamp=-1;float m_Received=-1,m_Playback=0,m_ReadAt=0;Array<ERR_ObserverViewSample*> m_Samples;
|
||||
'''+methods+r'''};
|
||||
+struct Baseline {RplId m_ScopeWeapon;vector m_ScopeData;RplId m_Target;int m_Stamp=-1;float m_Received=-1;vector m_Position,m_Forward,m_Up,m_StartPosition,m_StartForward,m_StartUp;float m_Fov=0,m_Near=0,m_StartFov=0,m_Blend=1;
|
||||
+'''+old_methods+r'''};
|
||||
bool close(float a,float b){return fabs(a-b)<0.0001;}
|
||||
int main(){
|
||||
vector pos{.1,1.6,.2},forward{0,0,1},up{0,1,0};
|
||||
@@ -42,11 +54,11 @@
|
||||
b.Push(1,100,100,pos,forward,up,60,.0125, -1, {});assert(b.Read(1,100,.01,matrix,fov,nearPlane));assert(close(matrix[3].y,1.6)&&close(fov,60));
|
||||
// Actual lean/roll and zoom, without forcing world-up or a hard-coded camera FOV.
|
||||
vector leanedUp{.6,.8,0};b.Push(1,150,150,{.3,1.4,.2},forward,leanedUp,30,.02, -1, {});
|
||||
- assert(b.Read(1,175,.025,matrix,fov,nearPlane));assert(close(matrix[3].x,.2)&&close(fov,45)&&matrix[1].x>0);
|
||||
- assert(b.Read(1,200,.025,matrix,fov,nearPlane));assert(close(matrix[3].x,.3)&&close(fov,30)&&close(matrix[1].x,.6)&&close(nearPlane,.02));
|
||||
- b.Push(1,149,220,{9,9,9},forward,up,80,.1, -1, {}); // out-of-order packet cannot rewind the view
|
||||
- assert(b.Read(1,225,.025,matrix,fov,nearPlane)&&close(matrix[3].x,.3));
|
||||
- for(int i=0;i<1000;i++)assert(b.Read(1,250,.016,matrix,fov,nearPlane)&&close(matrix[3].x,.3)); // no integration drift
|
||||
+ assert(b.Read(1,225,.025,matrix,fov,nearPlane));assert(close(matrix[3].x,.2)&&close(fov,45)&&matrix[1].x>0);
|
||||
+ assert(b.Read(1,250,.025,matrix,fov,nearPlane));assert(close(matrix[3].x,.3)&&close(fov,30)&&close(matrix[1].x,.6)&&close(nearPlane,.02));
|
||||
+ b.Push(1,149,270,{9,9,9},forward,up,80,.1, -1, {}); // out-of-order packet cannot rewind the view
|
||||
+ assert(b.Read(1,275,.025,matrix,fov,nearPlane)&&close(matrix[3].x,.3));
|
||||
+ for(int i=0;i<1000;i++)assert(b.Read(1,300,.016,matrix,fov,nearPlane)&&close(matrix[3].x,.3)); // no integration drift
|
||||
assert(!b.Read(1,501,.01,matrix,fov,nearPlane)); // gap beyond 350 ms, no stale head approximation
|
||||
assert(!b.Read(2,250,.01,matrix,fov,nearPlane)); // target switch never inherits previous pose
|
||||
b.Push(2,200,600,{1,2,3},forward,up,75,.1, -1, {});assert(b.Read(2,600,0,matrix,fov,nearPlane)&&close(matrix[3].x,1));
|
||||
@@ -62,6 +74,41 @@
|
||||
assert(!ERR_ObserverScope::Valid(22,{1,91,0}));
|
||||
assert(!ERR_ObserverScope::Valid(22,{1,0,2}));
|
||||
assert(!ERR_ObserverViewRules::Fresh(100,101));
|
||||
+ // Real interpolation bodies, same constant-speed poses, uneven packet arrivals.
|
||||
+ // Different client/server clock origins, dropped packets and varied render FPS.
|
||||
+ for(float frame:{1000.f/30,1000.f/60,1000.f/144}){
|
||||
+ Buffer smooth;Baseline old;int packet=0;float prevNew=0,prevOld=0,lastNewStep=0,lastOldStep=0;
|
||||
+ float newVariation=0,oldVariation=0;int delay[]={0,25,5,35,0,15};
|
||||
+ for(float t=0;t<2500;t+=frame){
|
||||
+ while(packet<=50&&packet*50+delay[packet%6]<=t){
|
||||
+ if(packet%13!=12){ // occasional single-packet loss
|
||||
+ vector location{packet*.05f,1.6,0};
|
||||
+ smooth.Push(1,70000+packet*50,10000+t,location,forward,up,60,.02,-1,{});
|
||||
+ old.Push(1,70000+packet*50,10000+t,location,forward,up,60,.02,-1,{});
|
||||
+ }
|
||||
+ packet++;
|
||||
+ }
|
||||
+ assert(smooth.Read(1,10000+t,frame/1000,matrix,fov,nearPlane));float nextNew=matrix[3].x;
|
||||
+ assert(old.Read(1,10000+t,frame/1000,matrix,fov,nearPlane));float nextOld=matrix[3].x;
|
||||
+ float newStep=nextNew-prevNew,oldStep=nextOld-prevOld;
|
||||
+ assert(newStep>=-0.0001); // no timeline rewind
|
||||
+ if(t>500){newVariation+=fabs(newStep-lastNewStep);oldVariation+=fabs(oldStep-lastOldStep);}
|
||||
+ prevNew=nextNew;prevOld=nextOld;lastNewStep=newStep;lastOldStep=oldStep;
|
||||
+ assert(smooth.m_Samples.Count()<=12);
|
||||
+ }
|
||||
+ std::cout<<"frame_ms="<<frame<<" buffered_step_variation="<<newVariation<<" baseline="<<oldVariation<<"\n";
|
||||
+ assert(newVariation<oldVariation*.25f);
|
||||
+ }
|
||||
+ // Holding an unrendered stream stays bounded; opening it resumes near live.
|
||||
+ Buffer parked;
|
||||
+ for(int i=0;i<1000;i++)parked.Push(1,i*50,i*50,pos,forward,up,60,.02,-1,{});
|
||||
+ assert(parked.m_Samples.Count()==12);
|
||||
+ assert(parked.Read(1,49950,0,matrix,fov,nearPlane)&&close(matrix[3].x,pos.x));
|
||||
+ // Scope changes, reacquisition after expiry and target changes snap to their
|
||||
+ // own pose instead of blending through another sight or another player.
|
||||
+ parked.Push(1,50000,50400,{2,1,0},forward,up,30,.02,-1,{});
|
||||
+ assert(parked.Read(1,50400,0,matrix,fov,nearPlane)&&close(matrix[3].x,2));
|
||||
+ parked.Clear();assert(parked.m_Samples.IsEmpty()&&parked.m_ScopeWeapon==RplId(-1));
|
||||
}
|
||||
''')
|
||||
-print('PASS: actual view buffer validates samples, follows position/roll/FOV, rejects stale/foreign/reordered data, interpolates without drift and expires gaps')
|
||||
+print('PASS: actual view buffer validates samples, follows position/roll/FOV, rejects stale/foreign/reordered data, buffers jitter without drift, bounds memory and expires gaps')
|
||||
Reference in New Issue
Block a user