Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ logs/
last_error.txt
log_*.log
.cursorrules
_Phase*
5 changes: 5 additions & 0 deletions DeadCellsMultiplayerMod.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,12 @@
<Optimize>true</Optimize>
</PropertyGroup>
<ItemGroup>
<Compile Remove="ProtocolReplay.Tests\**" />
<PackAssets Include="Resourcefile\**\*" RootInPak="DeadCellsMultiplayerModLang" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="DeadCellsMultiplayerMod.ProtocolReplay.Tests" />
</ItemGroup>

</Project>
66 changes: 0 additions & 66 deletions Ghost/KingActiveSkillsManager.cs

This file was deleted.

42 changes: 23 additions & 19 deletions Ghost/KingWeapon/KingWeaponsManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ public void update()
private void UpdateCore()
{
var hitchStart = RuntimeHitchWatch.Start();
var perfEnabled = RuntimeHitchWatch.Enabled;
if(hero == null) return;
var inv = king.inventory;
if(inventory == null && inv != null)
Expand Down Expand Up @@ -131,12 +132,13 @@ private void UpdateCore()

KingWeaponSupport.ActivateRemoteWeapon(candidate, king);
pendingInterrupts = 0;
LogKingWeaponsStepIfSlow(
"KingWeaponsManager.Rebuild",
rebuildStart,
string.Create(
System.Globalization.CultureInfo.InvariantCulture,
$"pendingSlot={pendingSlot} permanentId={item.permanentId} kind={kindId} weapon={weapon?.GetType().Name ?? "null"}"));
if(perfEnabled)
LogKingWeaponsStepIfSlow(
"KingWeaponsManager.Rebuild",
rebuildStart,
string.Create(
System.Globalization.CultureInfo.InvariantCulture,
$"pendingSlot={pendingSlot} permanentId={item.permanentId} kind={kindId} weapon={weapon?.GetType().Name ?? "null"}"));
}

var activeWeapon = weapon;
Expand Down Expand Up @@ -198,15 +200,16 @@ private void UpdateCore()
ReleaseShield(now);
}

LogKingWeaponsStepIfSlow(
"KingWeaponsManager.ShieldUpdate",
shieldStart,
string.Create(
System.Globalization.CultureInfo.InvariantCulture,
$"shieldActive={(_shieldActive ? 1 : 0)} pendingAttacks={pendingAttacks} pendingInterrupts={pendingInterrupts} weapon={activeWeapon.GetType().Name}"));
if(perfEnabled)
LogKingWeaponsStepIfSlow(
"KingWeaponsManager.ShieldUpdate",
shieldStart,
string.Create(
System.Globalization.CultureInfo.InvariantCulture,
$"shieldActive={(_shieldActive ? 1 : 0)} pendingAttacks={pendingAttacks} pendingInterrupts={pendingInterrupts} weapon={activeWeapon.GetType().Name}"));

var shieldTotalMs = RuntimeHitchWatch.GetElapsedMilliseconds(hitchStart);
if(shieldTotalMs >= RuntimeHitchWatch.GhostRuntimeSlowThresholdMs)
if(perfEnabled && shieldTotalMs >= RuntimeHitchWatch.GhostRuntimeSlowThresholdMs)
{
RuntimeHitchWatch.LogSlow(
ModEntry.Instance?.Logger,
Expand Down Expand Up @@ -449,12 +452,13 @@ private void ReleaseShield(long now)
_lastShieldReleaseTimestamp = now;
ClearShieldAffects();
RestoreRemoteIdlePose();
LogKingWeaponsStepIfSlow(
"KingWeaponsManager.ReleaseShield",
hitchStart,
string.Create(
System.Globalization.CultureInfo.InvariantCulture,
$"weapon={weapon?.GetType().Name ?? "null"}"));
if(RuntimeHitchWatch.Enabled)
LogKingWeaponsStepIfSlow(
"KingWeaponsManager.ReleaseShield",
hitchStart,
string.Create(
System.Globalization.CultureInfo.InvariantCulture,
$"weapon={weapon?.GetType().Name ?? "null"}"));
}

private void RestoreRemoteIdlePose()
Expand Down
25 changes: 25 additions & 0 deletions Ghost/Kinghead.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ public override void init(Level parent, dc.h2d.Object fromUI, Ref<bool> fromUI1)
this.forcedCustomHead = null!;
this._customHeadInfoCache = null!;
TryResolveRemoteCustomHeadInfo(remoteHeadSkin);
bool isBaseFlameDefault = string.Equals(remoteHeadSkin, "BaseFlame", StringComparison.Ordinal);
if (headSprite != null)
{
headMaterial = headSprite.frameData?.tile;
Expand All @@ -98,13 +99,15 @@ public override void init(Level parent, dc.h2d.Object fromUI, Ref<bool> fromUI1)
headParticleContainer = new dc.h2d.Object(fromUI);
}
InitBaseHead(parent, headParticleContainer, fromUI1);
TintBaseHeadEye(isBaseFlameDefault);
RebuildHeadParticles(headParticleContainer, headMaterial);
this.heroHasHead = true;
this.alwaysShowHead = true;
this.alwaysShowEye = true;
return;
}
InitBaseHead(parent, fromUI, fromUI1);
TintBaseHeadEye(isBaseFlameDefault);
this.heroHasHead = true;
this.alwaysShowHead = true;
this.alwaysShowEye = true;
Expand Down Expand Up @@ -181,6 +184,28 @@ private void InitBaseHead(Level parent, dc.h2d.Object attachParent, Ref<bool> fr
}
}

private void TintBaseHeadEye(bool isBaseFlameDefault)
{
if (!isBaseFlameDefault)
return;
try
{
var eyeSpr = this.eye;
if (eyeSpr == null)
return;

// Mirrors the lobby default head: the base homunculus eye star
// (fxSmallStar, Add blend) is tinted warm-orange 0xFFBF00 when usable.
var color = eyeSpr.color;
color.x = 1.0;
color.y = 191.0 / 255.0;
color.z = 0.0;
}
catch
{
}
}

private void RebuildHeadParticles(dc.h2d.Object particleParent, dc.h2d.Tile? material)
{
if (material == null)
Expand Down
41 changes: 41 additions & 0 deletions Interaction/InteractionSync.Cache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using dc.en;
using dc.en.inter;

namespace DeadCellsMultiplayerMod.Interaction;

public partial class InteractionSync
{
private sealed class LevelInteractionCache
{
public readonly List<Door> Doors = new();
public readonly List<Elevator> Elevators = new();
public readonly List<VineLadder> VineLadders = new();
public readonly List<Teleport> Teleports = new();
public readonly List<Portal> Portals = new();
public readonly List<PressurePlate> PressurePlates = new();
public readonly List<dc.en.inter.button.Button> Buttons = new();
public readonly List<TreasureChest> TreasureChests = new();
public readonly List<SwitchBossRune> SwitchBossRunes = new();
public readonly List<Elevator> TriggerElevators = new();
public readonly List<Teleport> TriggerTeleports = new();
public readonly List<Portal> TriggerPortals = new();
public readonly List<dc.en.inter.button.Button> TriggerButtons = new();

public void Clear()
{
Doors.Clear();
Elevators.Clear();
VineLadders.Clear();
Teleports.Clear();
Portals.Clear();
PressurePlates.Clear();
Buttons.Clear();
TreasureChests.Clear();
SwitchBossRunes.Clear();
TriggerElevators.Clear();
TriggerTeleports.Clear();
TriggerPortals.Clear();
TriggerButtons.Clear();
}
}
}
83 changes: 64 additions & 19 deletions Interaction/InteractionSync.Doors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -173,13 +173,13 @@ private string GetAuthoritativeDoorState(Door door)
// A locked door is script-controlled (boss arena seals). Never advertise it as open:
// the stale _openedDoors entry from walking through it earlier otherwise made the 2s
// heartbeat force the client's sealed door back open for the whole fight.
if (TryReadBooleanMember(door, "locked", "isLocked"))
if (TryReadBooleanMember(door, LockedMemberNames))
return "state_closed";

if (TryReadBooleanMember(door, "opened", "isOpen", "open"))
if (TryReadBooleanMember(door, OpenedMemberNames))
return "state_open";

var ratio = TryReadNumericMember(door, "ratio", "openRatio", "openingRatio", "curRatio");
var ratio = TryReadNumericMember(door, RatioMemberNames);
if (ratio.HasValue)
return ratio.Value > 0.45 ? "state_open" : "state_closed";

Expand All @@ -191,12 +191,64 @@ private bool ShouldRejectRemoteDoorOpen(Door door)
{
// Script-sealed doors (boss arena locks) must never be reopened by replayed remote
// events or heartbeats; the local fight script owns them until the encounter ends.
if (TryReadBooleanMember(door, "locked", "isLocked"))
if (TryReadBooleanMember(door, LockedMemberNames))
return true;

return DeadCellsMultiplayerMod.Mobs.MobsSynchronization.MobsSynchronization.HasLivingTrackedBoss();
}

// Per-frame host hot path: BroadcastAuthoritativeDoorStates re-reads every door's native
// state (broken/locked/opened/ratio) once per frame. Raw Type.GetProperty/GetField would
// re-run a full metadata scan per door per frame. Member names are fixed literals and the
// resolved PropertyInfo/FieldInfo are stable for the runtime type, so cache resolution per
// (Type, name). Not-found names are cached too so missing members are probed only once.
// Main-thread only (IOnHeroUpdate.OnHeroUpdate).
private readonly struct DoorMemberResolution
{
public readonly PropertyInfo? Property;
public readonly FieldInfo? Field;

public DoorMemberResolution(PropertyInfo? property, FieldInfo? field)
{
Property = property;
Field = field;
}
}

private static readonly Dictionary<(System.Type Type, string Name), DoorMemberResolution> s_doorMemberResolutions = new();

// Fixed literal probe lists for GetAuthoritativeDoorState/ShouldRejectRemoteDoorOpen.
// Passed to params helpers as pre-built arrays so the per-frame door broadcast does not
// allocate a fresh params String[] per door per frame.
private static readonly string[] LockedMemberNames = { "locked", "isLocked" };
private static readonly string[] OpenedMemberNames = { "opened", "isOpen", "open" };
private static readonly string[] RatioMemberNames = { "ratio", "openRatio", "openingRatio", "curRatio" };

private static DoorMemberResolution ResolveDoorMember(System.Type type, string name)
{
var key = (type, name);
if (s_doorMemberResolutions.TryGetValue(key, out var cached))
return cached;

PropertyInfo? property = null;
FieldInfo? field = null;
try
{
property = type.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (property?.CanRead != true)
property = null;
field = type.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
}
catch
{
// Ignore optional/generated members that cannot be resolved in this game build.
}

var resolution = new DoorMemberResolution(property, field);
s_doorMemberResolutions[key] = resolution;
return resolution;
}

private static double? TryReadNumericMember(object instance, params string[] names)
{
if (instance == null)
Expand All @@ -207,18 +259,12 @@ private bool ShouldRejectRemoteDoorOpen(Door door)
{
try
{
var property = type.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (property?.CanRead == true)
{
var value = property.GetValue(instance);
if (value != null)
return Convert.ToDouble(value, System.Globalization.CultureInfo.InvariantCulture);
}
var member = ResolveDoorMember(type, name);
if (member.Property?.GetValue(instance) is { } pv)
return Convert.ToDouble(pv, System.Globalization.CultureInfo.InvariantCulture);

var field = type.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
var fieldValue = field?.GetValue(instance);
if (fieldValue != null)
return Convert.ToDouble(fieldValue, System.Globalization.CultureInfo.InvariantCulture);
if (member.Field?.GetValue(instance) is { } fv)
return Convert.ToDouble(fv, System.Globalization.CultureInfo.InvariantCulture);
}
catch
{
Expand Down Expand Up @@ -466,12 +512,11 @@ private static bool TryReadBooleanMember(object instance, params string[] names)
{
try
{
var property = type.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (property?.CanRead == true && property.GetValue(instance) is bool propertyValue)
var member = ResolveDoorMember(type, name);
if (member.Property?.GetValue(instance) is bool propertyValue)
return propertyValue;

var field = type.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (field?.GetValue(instance) is bool fieldValue)
if (member.Field?.GetValue(instance) is bool fieldValue)
return fieldValue;
}
catch
Expand Down
Loading
Loading