feat(headless): share immutable gameplay content
This commit is contained in:
parent
12b500d383
commit
9569dadb57
25 changed files with 1031 additions and 429 deletions
266
src/AcDream.Content/LandblockPhysicsContentBuilder.cs
Normal file
266
src/AcDream.Content/LandblockPhysicsContentBuilder.cs
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
using System.Collections.Immutable;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Meshing;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatEnvironment = DatReaderWriter.DBObjs.Environment;
|
||||
|
||||
namespace AcDream.Content;
|
||||
|
||||
/// <summary>
|
||||
/// Presentation-independent content half of one near-landblock collision
|
||||
/// build. Graphical and no-window hosts consume the same parsed DAT closure,
|
||||
/// immutable package collision records, and static Setup/GfxObj identities.
|
||||
/// No Runtime world or backend resource is mutated here.
|
||||
/// </summary>
|
||||
public static class LandblockPhysicsContentBuilder
|
||||
{
|
||||
public static IReadOnlyList<WorldEntity> HydrateStaticEntities(
|
||||
IDatReaderWriter dats,
|
||||
LoadedLandblock source,
|
||||
Vector3 worldOffset)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
var hydrated = new List<WorldEntity>(source.Entities.Count);
|
||||
foreach (WorldEntity candidate in source.Entities)
|
||||
{
|
||||
var meshRefs = new List<MeshRef>();
|
||||
var bounds = new LocalBoundsAccumulator();
|
||||
uint sourceId = candidate.SourceGfxObjOrSetupId;
|
||||
if ((sourceId & 0xFF000000u) == 0x01000000u)
|
||||
{
|
||||
GfxObj? gfx = dats.Get<GfxObj>(sourceId);
|
||||
if (gfx is not null)
|
||||
{
|
||||
(Vector3 Min, Vector3 Max)? partBounds =
|
||||
GfxObjBounds.Get(gfx);
|
||||
if (partBounds is not null)
|
||||
{
|
||||
bounds.Add(
|
||||
Matrix4x4.Identity,
|
||||
partBounds.Value);
|
||||
}
|
||||
meshRefs.Add(new MeshRef(
|
||||
sourceId,
|
||||
Matrix4x4.Identity));
|
||||
}
|
||||
}
|
||||
else if ((sourceId & 0xFF000000u) == 0x02000000u)
|
||||
{
|
||||
Setup? setup = dats.Get<Setup>(sourceId);
|
||||
if (setup is not null)
|
||||
{
|
||||
foreach (MeshRef meshRef in SetupMesh.Flatten(setup))
|
||||
{
|
||||
GfxObj? gfx =
|
||||
dats.Get<GfxObj>(meshRef.GfxObjId);
|
||||
if (gfx is null)
|
||||
continue;
|
||||
(Vector3 Min, Vector3 Max)? partBounds =
|
||||
GfxObjBounds.Get(gfx);
|
||||
if (partBounds is not null)
|
||||
{
|
||||
bounds.Add(
|
||||
meshRef.PartTransform,
|
||||
partBounds.Value);
|
||||
}
|
||||
meshRefs.Add(meshRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (meshRefs.Count == 0)
|
||||
continue;
|
||||
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = candidate.Id,
|
||||
SourceGfxObjOrSetupId = sourceId,
|
||||
Position = candidate.Position + worldOffset,
|
||||
Rotation = candidate.Rotation,
|
||||
MeshRefs = meshRefs,
|
||||
EffectCellId = candidate.EffectCellId,
|
||||
IsBuildingShell = candidate.IsBuildingShell,
|
||||
BuildingShellAnchorCellId =
|
||||
candidate.BuildingShellAnchorCellId,
|
||||
};
|
||||
if (bounds.TryGet(out Vector3 min, out Vector3 max))
|
||||
entity.SetLocalBounds(min, max);
|
||||
hydrated.Add(entity);
|
||||
}
|
||||
return hydrated;
|
||||
}
|
||||
|
||||
public static PhysicsDatBundle BuildDatBundle(
|
||||
IDatReaderWriter dats,
|
||||
uint landblockId,
|
||||
IReadOnlyList<WorldEntity> entities)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
ArgumentNullException.ThrowIfNull(entities);
|
||||
|
||||
var envCells = new Dictionary<uint, EnvCell>();
|
||||
var environments = new Dictionary<uint, DatEnvironment>();
|
||||
var setups = new Dictionary<uint, Setup>();
|
||||
var gfxObjs = new Dictionary<uint, GfxObj>();
|
||||
LandBlockInfo? info = dats.Get<LandBlockInfo>(
|
||||
(landblockId & 0xFFFF0000u) | 0xFFFEu);
|
||||
|
||||
if (info is not null)
|
||||
{
|
||||
uint firstCellId =
|
||||
(landblockId & 0xFFFF0000u) | 0x0100u;
|
||||
for (uint offset = 0; offset < info.NumCells; offset++)
|
||||
{
|
||||
uint envCellId = firstCellId + offset;
|
||||
EnvCell? envCell = dats.Get<EnvCell>(envCellId);
|
||||
if (envCell is null)
|
||||
continue;
|
||||
envCells[envCellId] = envCell;
|
||||
if (envCell.EnvironmentId == 0)
|
||||
continue;
|
||||
uint environmentId =
|
||||
0x0D000000u | envCell.EnvironmentId;
|
||||
if (!environments.ContainsKey(environmentId)
|
||||
&& dats.Get<DatEnvironment>(
|
||||
environmentId) is { } environment)
|
||||
{
|
||||
environments[environmentId] = environment;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var building in info.Buildings)
|
||||
{
|
||||
uint setupId = building.ModelId;
|
||||
if ((setupId & 0xFF000000u) == 0x02000000u
|
||||
&& !setups.ContainsKey(setupId)
|
||||
&& dats.Get<Setup>(setupId) is { } setup)
|
||||
{
|
||||
setups[setupId] = setup;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (WorldEntity entity in entities)
|
||||
{
|
||||
uint sourceId = entity.SourceGfxObjOrSetupId;
|
||||
if ((sourceId & 0xFF000000u) == 0x02000000u
|
||||
&& !setups.ContainsKey(sourceId)
|
||||
&& dats.Get<Setup>(sourceId) is { } setup)
|
||||
{
|
||||
setups[sourceId] = setup;
|
||||
}
|
||||
|
||||
foreach (MeshRef meshRef in entity.MeshRefs)
|
||||
{
|
||||
uint gfxObjId = meshRef.GfxObjId;
|
||||
if ((gfxObjId & 0xFF000000u) != 0x01000000u
|
||||
|| gfxObjs.ContainsKey(gfxObjId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (dats.Get<GfxObj>(gfxObjId) is { } gfxObj)
|
||||
gfxObjs[gfxObjId] = gfxObj;
|
||||
}
|
||||
}
|
||||
|
||||
return new PhysicsDatBundle(
|
||||
info,
|
||||
envCells,
|
||||
environments,
|
||||
setups,
|
||||
gfxObjs);
|
||||
}
|
||||
|
||||
public static LandblockCollisionBuild BuildPreparedCollisionClosure(
|
||||
IPreparedCollisionSource source,
|
||||
LoadedLandblock landblock)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
ArgumentNullException.ThrowIfNull(landblock);
|
||||
|
||||
PhysicsDatBundle dats =
|
||||
landblock.PhysicsDats ?? PhysicsDatBundle.Empty;
|
||||
ImmutableArray<uint> gfxObjIds =
|
||||
[.. dats.GfxObjs.Keys.Order()];
|
||||
ImmutableArray<uint> setupIds =
|
||||
[.. dats.Setups.Keys.Order()];
|
||||
ImmutableArray<uint> envCellIds =
|
||||
[.. dats.EnvCells.Keys.Order()];
|
||||
var gfxObjs = ImmutableDictionary.CreateBuilder<
|
||||
uint,
|
||||
FlatGfxObjCollisionAsset>();
|
||||
var setups = ImmutableDictionary.CreateBuilder<
|
||||
uint,
|
||||
FlatSetupCollision>();
|
||||
var cellStructures = ImmutableDictionary.CreateBuilder<
|
||||
uint,
|
||||
FlatCellStructureCollisionAsset>();
|
||||
var envCells = ImmutableDictionary.CreateBuilder<
|
||||
uint,
|
||||
FlatEnvCellTopology>();
|
||||
|
||||
foreach (uint id in gfxObjIds)
|
||||
{
|
||||
gfxObjs.Add(
|
||||
id,
|
||||
Require(
|
||||
source.ReadGfxObjCollision(id),
|
||||
"GfxObj collision",
|
||||
id));
|
||||
}
|
||||
foreach (uint id in setupIds)
|
||||
{
|
||||
setups.Add(
|
||||
id,
|
||||
Require(
|
||||
source.ReadSetupCollision(id),
|
||||
"Setup collision",
|
||||
id));
|
||||
}
|
||||
foreach (uint id in envCellIds)
|
||||
{
|
||||
cellStructures.Add(
|
||||
id,
|
||||
Require(
|
||||
source.ReadCellStructureCollision(id),
|
||||
"CellStruct collision",
|
||||
id));
|
||||
envCells.Add(
|
||||
id,
|
||||
Require(
|
||||
source.ReadEnvCellTopology(id),
|
||||
"EnvCell topology",
|
||||
id));
|
||||
}
|
||||
|
||||
return new LandblockCollisionBuild(
|
||||
gfxObjs.ToImmutable(),
|
||||
setups.ToImmutable(),
|
||||
cellStructures.ToImmutable(),
|
||||
envCells.ToImmutable(),
|
||||
gfxObjIds,
|
||||
setupIds,
|
||||
envCellIds);
|
||||
}
|
||||
|
||||
private static T Require<T>(
|
||||
PreparedCollisionReadResult<T> result,
|
||||
string kind,
|
||||
uint id)
|
||||
where T : class
|
||||
{
|
||||
if (result.Status == PreparedAssetReadStatus.Loaded
|
||||
&& result.Data is not null)
|
||||
{
|
||||
return result.Data;
|
||||
}
|
||||
|
||||
throw new InvalidDataException(
|
||||
$"{kind} 0x{id:X8} is {result.Status}. "
|
||||
+ "The complete near-tier generation cannot be published.");
|
||||
}
|
||||
}
|
||||
192
src/AcDream.Content/MagicCatalog.cs
Normal file
192
src/AcDream.Content/MagicCatalog.cs
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
using System.Collections.Frozen;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Spells;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using CoreSpellTable = AcDream.Core.Spells.SpellTable;
|
||||
|
||||
namespace AcDream.Content;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable presentation and gameplay metadata for one retail spell
|
||||
/// component WCID.
|
||||
/// </summary>
|
||||
public sealed record SpellComponentDescriptor(
|
||||
uint WeenieClassId,
|
||||
string Name,
|
||||
uint Category,
|
||||
uint IconId);
|
||||
|
||||
/// <summary>
|
||||
/// Process-shareable projection of retail's spell, component, and
|
||||
/// school-focus DAT tables. The catalog contains no player, inventory,
|
||||
/// session, or presentation state.
|
||||
/// </summary>
|
||||
public sealed class MagicCatalog
|
||||
{
|
||||
private const uint SpellTableDid = 0x0E00000Eu;
|
||||
private const uint SpellComponentTableDid = 0x0E00000Fu;
|
||||
private const uint ComponentIdMapDid = 0x27000002u;
|
||||
|
||||
private readonly FrozenDictionary<uint, SpellFormulaDefinition>
|
||||
_formulaDefinitions;
|
||||
private readonly FrozenDictionary<uint, uint> _wcidByScid;
|
||||
private readonly FrozenDictionary<uint, uint> _magicPackWcidBySchool;
|
||||
private readonly FrozenDictionary<uint, int> _spellLevels;
|
||||
|
||||
private MagicCatalog(
|
||||
CoreSpellTable spellTable,
|
||||
FrozenDictionary<uint, SpellComponentDescriptor> components,
|
||||
FrozenDictionary<uint, SpellFormulaDefinition> formulaDefinitions,
|
||||
FrozenDictionary<uint, uint> wcidByScid,
|
||||
FrozenDictionary<uint, uint> magicPackWcidBySchool,
|
||||
FrozenDictionary<uint, int> spellLevels)
|
||||
{
|
||||
SpellTable = spellTable;
|
||||
Components = components;
|
||||
_formulaDefinitions = formulaDefinitions;
|
||||
_wcidByScid = wcidByScid;
|
||||
_magicPackWcidBySchool = magicPackWcidBySchool;
|
||||
_spellLevels = spellLevels;
|
||||
}
|
||||
|
||||
public static MagicCatalog Empty { get; } = new(
|
||||
CoreSpellTable.Empty,
|
||||
EmptyFrozen<SpellComponentDescriptor>(),
|
||||
EmptyFrozen<SpellFormulaDefinition>(),
|
||||
EmptyFrozen<uint>(),
|
||||
EmptyFrozen<uint>(),
|
||||
EmptyFrozen<int>());
|
||||
|
||||
public CoreSpellTable SpellTable { get; }
|
||||
public IReadOnlyDictionary<uint, SpellComponentDescriptor> Components
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
public bool TryGetComponentBySpellComponentId(
|
||||
uint spellComponentId,
|
||||
out SpellComponentDescriptor descriptor)
|
||||
{
|
||||
if (_wcidByScid.TryGetValue(
|
||||
spellComponentId,
|
||||
out uint weenieClassId)
|
||||
&& Components.TryGetValue(
|
||||
weenieClassId,
|
||||
out SpellComponentDescriptor? found))
|
||||
{
|
||||
descriptor = found;
|
||||
return true;
|
||||
}
|
||||
descriptor = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool IsComponentPack(uint weenieClassId) =>
|
||||
Components.ContainsKey(weenieClassId);
|
||||
|
||||
public int GetSpellLevel(uint spellId) =>
|
||||
_spellLevels.TryGetValue(spellId, out int level)
|
||||
? level
|
||||
: 0;
|
||||
|
||||
/// <summary>
|
||||
/// Creates the per-session policy view over this immutable catalog. The
|
||||
/// returned service borrows only the supplied session's object table and
|
||||
/// identity/account delegates.
|
||||
/// </summary>
|
||||
public SpellComponentRequirementService CreateRequirementService(
|
||||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
Func<string> accountName) =>
|
||||
new(
|
||||
objects,
|
||||
playerGuid,
|
||||
accountName,
|
||||
_formulaDefinitions,
|
||||
_wcidByScid,
|
||||
_magicPackWcidBySchool);
|
||||
|
||||
public static MagicCatalog Load(IDatReaderWriter dats)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
|
||||
var components =
|
||||
new Dictionary<uint, SpellComponentDescriptor>();
|
||||
var wcidByScid = new Dictionary<uint, uint>();
|
||||
SpellComponentTable? componentTable =
|
||||
dats.Get<SpellComponentTable>(SpellComponentTableDid);
|
||||
DualEnumIDMap? componentIds =
|
||||
dats.Get<DualEnumIDMap>(ComponentIdMapDid);
|
||||
if (componentTable is not null && componentIds is not null)
|
||||
{
|
||||
foreach (var pair in componentTable.Components)
|
||||
{
|
||||
if (!componentIds.ClientEnumToID.TryGetValue(
|
||||
pair.Key,
|
||||
out uint wcid))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
wcidByScid[pair.Key] = wcid;
|
||||
components[wcid] = new SpellComponentDescriptor(
|
||||
wcid,
|
||||
pair.Value.Name.Value,
|
||||
pair.Value.Category,
|
||||
pair.Value.Icon.DataId);
|
||||
}
|
||||
}
|
||||
|
||||
var metadata = new List<SpellMetadata>();
|
||||
var formulaDefinitions =
|
||||
new Dictionary<uint, SpellFormulaDefinition>();
|
||||
var spellLevels = new Dictionary<uint, int>();
|
||||
DatReaderWriter.DBObjs.SpellTable spellTable =
|
||||
dats.Get<DatReaderWriter.DBObjs.SpellTable>(SpellTableDid)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Required retail SpellTable 0x{SpellTableDid:X8} is missing from portal.dat.");
|
||||
foreach (var pair in spellTable.Spells)
|
||||
{
|
||||
SpellMetadata spell = RetailSpellMetadataProjector.Project(
|
||||
pair.Key,
|
||||
pair.Value,
|
||||
componentTable);
|
||||
metadata.Add(spell);
|
||||
uint[] formula = spell.FormulaComponents.ToArray();
|
||||
formulaDefinitions[pair.Key] = new SpellFormulaDefinition(
|
||||
spell.FormulaVersion,
|
||||
formula,
|
||||
(uint)spell.SchoolId);
|
||||
spellLevels[pair.Key] = spell.Generation;
|
||||
}
|
||||
|
||||
var magicPackWcidBySchool = new Dictionary<uint, uint>();
|
||||
// Retail IsComponentPack resolves enum key 0x10000001 in category
|
||||
// 0x28; that key is not itself a portal.dat file id.
|
||||
uint magicPackMapDid = RetailDataIdResolver.Resolve(
|
||||
dats,
|
||||
0x10000001u,
|
||||
0x28u);
|
||||
if (magicPackMapDid != 0u
|
||||
&& dats.Portal.TryGet<EnumIDMap>(
|
||||
magicPackMapDid,
|
||||
out EnumIDMap? magicPackMap)
|
||||
&& magicPackMap is not null)
|
||||
{
|
||||
foreach (var pair in magicPackMap.ClientEnumToID)
|
||||
magicPackWcidBySchool[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
return new MagicCatalog(
|
||||
CoreSpellTable.Create(metadata),
|
||||
components.ToFrozenDictionary(),
|
||||
formulaDefinitions.ToFrozenDictionary(),
|
||||
wcidByScid.ToFrozenDictionary(),
|
||||
magicPackWcidBySchool.ToFrozenDictionary(),
|
||||
spellLevels.ToFrozenDictionary());
|
||||
}
|
||||
|
||||
private static FrozenDictionary<uint, TValue> EmptyFrozen<TValue>() =>
|
||||
new Dictionary<uint, TValue>().ToFrozenDictionary();
|
||||
}
|
||||
28
src/AcDream.Content/RetailDataIdResolver.cs
Normal file
28
src/AcDream.Content/RetailDataIdResolver.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.Content;
|
||||
|
||||
/// <summary>
|
||||
/// Ports <c>DBCache::GetDIDFromEnumStatic @ 0x00413940</c>: the portal master map
|
||||
/// selects an enum category, then that category map resolves the client enum value.
|
||||
/// </summary>
|
||||
public static class RetailDataIdResolver
|
||||
{
|
||||
public static uint Resolve(IDatReaderWriter dats, uint enumValue, uint enumCategory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
|
||||
uint masterDid = (uint)dats.Portal.Db.Header.MasterMapId;
|
||||
if (masterDid == 0
|
||||
|| !dats.Portal.TryGet<EnumIDMap>(masterDid, out var master)
|
||||
|| master is null
|
||||
|| !master.ClientEnumToID.TryGetValue(enumCategory, out uint subMapDid)
|
||||
|| !dats.Portal.TryGet<EnumIDMap>(subMapDid, out var subMap)
|
||||
|| subMap is null
|
||||
|| !subMap.ClientEnumToID.TryGetValue(enumValue, out uint did))
|
||||
return 0u;
|
||||
|
||||
return did;
|
||||
}
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@ public sealed class RuntimeDatCollection : IDatReaderWriter
|
|||
{
|
||||
private readonly DatCollection _raw;
|
||||
private readonly DatCollectionAdapter _bounded;
|
||||
private bool _disposed;
|
||||
private int _disposeStage;
|
||||
|
||||
internal RuntimeDatCollection(DatCollection raw)
|
||||
{
|
||||
|
|
@ -95,11 +95,22 @@ public sealed class RuntimeDatCollection : IDatReaderWriter
|
|||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
_bounded.Dispose();
|
||||
_raw.Dispose();
|
||||
while (_disposeStage < 2)
|
||||
{
|
||||
switch (_disposeStage)
|
||||
{
|
||||
case 0:
|
||||
_bounded.Dispose();
|
||||
_disposeStage++;
|
||||
break;
|
||||
case 1:
|
||||
_raw.Dispose();
|
||||
_disposeStage++;
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
"Unknown DAT owner teardown stage.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
245
src/AcDream.Content/SharedPreparedCollisionCache.cs
Normal file
245
src/AcDream.Content/SharedPreparedCollisionCache.cs
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
using AcDream.Content.Pak;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.Content;
|
||||
|
||||
public readonly record struct SharedPreparedCollisionCacheSnapshot(
|
||||
int Count,
|
||||
int Capacity,
|
||||
long Hits,
|
||||
long Misses,
|
||||
bool IsDisposed)
|
||||
{
|
||||
public bool IsBounded => Count >= 0 && Count <= Capacity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process-scoped bounded cache over immutable prepared collision payloads.
|
||||
/// The wrapped package remains owned by the caller; disposing this cache only
|
||||
/// retires its decoded references. Sessions retain separate
|
||||
/// <see cref="PhysicsDataCache"/> instances and share only these immutable
|
||||
/// values.
|
||||
/// </summary>
|
||||
public sealed class SharedPreparedCollisionCache
|
||||
: IPreparedCollisionSource
|
||||
{
|
||||
public const int DefaultCapacity = 4096;
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly IPreparedCollisionSource _source;
|
||||
private readonly int _capacity;
|
||||
private readonly Dictionary<CacheKey, LinkedListNode<CacheEntry>>
|
||||
_entries = new();
|
||||
private readonly LinkedList<CacheEntry> _lru = new();
|
||||
private long _probes;
|
||||
private long _reads;
|
||||
private long _loaded;
|
||||
private long _missing;
|
||||
private long _corrupt;
|
||||
private long _hits;
|
||||
private long _misses;
|
||||
private bool _disposed;
|
||||
|
||||
public SharedPreparedCollisionCache(
|
||||
IPreparedCollisionSource source,
|
||||
int capacity = DefaultCapacity)
|
||||
{
|
||||
_source = source ?? throw new ArgumentNullException(nameof(source));
|
||||
if (capacity <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(capacity));
|
||||
_capacity = capacity;
|
||||
}
|
||||
|
||||
public PreparedCollisionSourceStats CollisionStats => new(
|
||||
Interlocked.Read(ref _probes),
|
||||
Interlocked.Read(ref _reads),
|
||||
Interlocked.Read(ref _loaded),
|
||||
Interlocked.Read(ref _missing),
|
||||
Interlocked.Read(ref _corrupt));
|
||||
|
||||
public SharedPreparedCollisionCacheSnapshot CaptureSnapshot()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return new SharedPreparedCollisionCacheSnapshot(
|
||||
_entries.Count,
|
||||
_capacity,
|
||||
Interlocked.Read(ref _hits),
|
||||
Interlocked.Read(ref _misses),
|
||||
_disposed);
|
||||
}
|
||||
}
|
||||
|
||||
public PreparedAssetPresence ProbeCollision(
|
||||
PakAssetType type,
|
||||
uint sourceFileId)
|
||||
{
|
||||
PreparedCollisionTypeContract.Validate(type);
|
||||
Interlocked.Increment(ref _probes);
|
||||
lock (_gate)
|
||||
{
|
||||
EnsureNotDisposed();
|
||||
if (_entries.TryGetValue(
|
||||
new CacheKey(type, sourceFileId),
|
||||
out LinkedListNode<CacheEntry>? node))
|
||||
{
|
||||
Touch(node);
|
||||
return node.Value.Status switch
|
||||
{
|
||||
PreparedAssetReadStatus.Loaded =>
|
||||
PreparedAssetPresence.Available,
|
||||
PreparedAssetReadStatus.Corrupt =>
|
||||
PreparedAssetPresence.Corrupt,
|
||||
_ => PreparedAssetPresence.Missing,
|
||||
};
|
||||
}
|
||||
return _source.ProbeCollision(type, sourceFileId);
|
||||
}
|
||||
}
|
||||
|
||||
public PreparedCollisionReadResult<FlatGfxObjCollisionAsset>
|
||||
ReadGfxObjCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Read(
|
||||
PakAssetType.GfxObjCollision,
|
||||
sourceFileId,
|
||||
token => _source.ReadGfxObjCollision(sourceFileId, token),
|
||||
cancellationToken);
|
||||
|
||||
public PreparedCollisionReadResult<FlatSetupCollision>
|
||||
ReadSetupCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Read(
|
||||
PakAssetType.SetupCollision,
|
||||
sourceFileId,
|
||||
token => _source.ReadSetupCollision(sourceFileId, token),
|
||||
cancellationToken);
|
||||
|
||||
public PreparedCollisionReadResult<FlatCellStructureCollisionAsset>
|
||||
ReadCellStructureCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Read(
|
||||
PakAssetType.CellStructureCollision,
|
||||
sourceFileId,
|
||||
token => _source.ReadCellStructureCollision(
|
||||
sourceFileId,
|
||||
token),
|
||||
cancellationToken);
|
||||
|
||||
public PreparedCollisionReadResult<FlatEnvCellTopology>
|
||||
ReadEnvCellTopology(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Read(
|
||||
PakAssetType.EnvCellTopology,
|
||||
sourceFileId,
|
||||
token => _source.ReadEnvCellTopology(sourceFileId, token),
|
||||
cancellationToken);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_entries.Clear();
|
||||
_lru.Clear();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private PreparedCollisionReadResult<T> Read<T>(
|
||||
PakAssetType type,
|
||||
uint sourceFileId,
|
||||
Func<CancellationToken, PreparedCollisionReadResult<T>> loader,
|
||||
CancellationToken cancellationToken)
|
||||
where T : class
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
Interlocked.Increment(ref _reads);
|
||||
lock (_gate)
|
||||
{
|
||||
EnsureNotDisposed();
|
||||
var key = new CacheKey(type, sourceFileId);
|
||||
if (_entries.TryGetValue(
|
||||
key,
|
||||
out LinkedListNode<CacheEntry>? node))
|
||||
{
|
||||
Touch(node);
|
||||
Interlocked.Increment(ref _hits);
|
||||
return CountAndConvert<T>(node.Value);
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _misses);
|
||||
PreparedCollisionReadResult<T> loaded =
|
||||
loader(cancellationToken);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var entry = new CacheEntry(
|
||||
key,
|
||||
loaded.Status,
|
||||
loaded.Data);
|
||||
LinkedListNode<CacheEntry> added = _lru.AddFirst(entry);
|
||||
_entries.Add(key, added);
|
||||
Trim();
|
||||
return CountAndConvert<T>(entry);
|
||||
}
|
||||
}
|
||||
|
||||
private PreparedCollisionReadResult<T> CountAndConvert<T>(
|
||||
CacheEntry entry)
|
||||
where T : class
|
||||
{
|
||||
switch (entry.Status)
|
||||
{
|
||||
case PreparedAssetReadStatus.Loaded
|
||||
when entry.Data is T data:
|
||||
Interlocked.Increment(ref _loaded);
|
||||
return PreparedCollisionReadResult<T>.Loaded(data);
|
||||
case PreparedAssetReadStatus.Missing:
|
||||
Interlocked.Increment(ref _missing);
|
||||
return PreparedCollisionReadResult<T>.Missing;
|
||||
case PreparedAssetReadStatus.Corrupt:
|
||||
Interlocked.Increment(ref _corrupt);
|
||||
return PreparedCollisionReadResult<T>.Corrupt;
|
||||
default:
|
||||
throw new InvalidDataException(
|
||||
$"Prepared collision cache entry {entry.Key} has an invalid payload.");
|
||||
}
|
||||
}
|
||||
|
||||
private void Touch(LinkedListNode<CacheEntry> node)
|
||||
{
|
||||
if (ReferenceEquals(_lru.First, node))
|
||||
return;
|
||||
_lru.Remove(node);
|
||||
_lru.AddFirst(node);
|
||||
}
|
||||
|
||||
private void Trim()
|
||||
{
|
||||
while (_entries.Count > _capacity)
|
||||
{
|
||||
LinkedListNode<CacheEntry> tail =
|
||||
_lru.Last
|
||||
?? throw new InvalidOperationException(
|
||||
"Prepared collision LRU lost its terminal entry.");
|
||||
_entries.Remove(tail.Value.Key);
|
||||
_lru.RemoveLast();
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureNotDisposed() =>
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
private readonly record struct CacheKey(
|
||||
PakAssetType Type,
|
||||
uint SourceFileId);
|
||||
|
||||
private sealed record CacheEntry(
|
||||
CacheKey Key,
|
||||
PreparedAssetReadStatus Status,
|
||||
object? Data);
|
||||
}
|
||||
140
src/AcDream.Content/Spells/RetailSpellMetadataProjector.cs
Normal file
140
src/AcDream.Content/Spells/RetailSpellMetadataProjector.cs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using AcDream.Core.Spells;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
using CoreMagicSchool = AcDream.Core.Spells.MagicSchool;
|
||||
|
||||
namespace AcDream.Content;
|
||||
|
||||
/// <summary>
|
||||
/// Projects portal.dat's complete retail <see cref="SpellBase"/> record into
|
||||
/// the backend-neutral Core metadata consumed by spellbook and casting state.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Schema/order: <c>CSpellBase::UnPack @ 0x00597290</c>.
|
||||
/// School names: <c>CSpellBase::SchoolEnumToName @ 0x00597400</c>.
|
||||
/// Target type: <c>SpellFormula::GetTargetingType @ 0x005BC910</c>.
|
||||
/// </remarks>
|
||||
public static class RetailSpellMetadataProjector
|
||||
{
|
||||
public static SpellMetadata Project(
|
||||
uint spellId,
|
||||
SpellBase spell,
|
||||
SpellComponentTable? componentTable)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(spell);
|
||||
|
||||
uint[] formula = spell.Components.Take(8).ToArray();
|
||||
uint flags = (uint)spell.Bitfield;
|
||||
uint formulaTargetType = RetailSpellFormula.GetTargetingType(formula);
|
||||
uint targetMask = formulaTargetType;
|
||||
int level = RetailSpellFormula.InqSpellLevelByRoughHeuristic(formula);
|
||||
bool selfTargeted = (flags & (uint)SpellFlags.SelfTargeted) != 0u;
|
||||
bool beneficial = (flags & (uint)SpellFlags.Beneficial) != 0u;
|
||||
|
||||
return new SpellMetadata(
|
||||
spellId,
|
||||
spell.Name.Value,
|
||||
SchoolName(spell.School),
|
||||
(uint)spell.Category,
|
||||
spell.Icon,
|
||||
BuildSpellWords(formula, componentTable),
|
||||
checked((float)spell.Duration),
|
||||
checked((int)spell.BaseMana),
|
||||
(flags & (uint)SpellFlags.Reversed) != 0u,
|
||||
(flags & (uint)SpellFlags.FellowshipSpell) != 0u,
|
||||
spell.Description.Value,
|
||||
unchecked((int)spell.DisplayOrder),
|
||||
checked((int)spell.Power),
|
||||
flags,
|
||||
level,
|
||||
(flags & (uint)SpellFlags.FastCast) != 0u,
|
||||
!beneficial && !selfTargeted,
|
||||
formulaTargetType == 0u,
|
||||
Speed: 0f,
|
||||
(uint)spell.CasterEffect,
|
||||
(uint)spell.TargetEffect,
|
||||
targetMask,
|
||||
checked((int)spell.MetaSpellType))
|
||||
{
|
||||
SchoolId = ToCoreSchool(spell.School),
|
||||
FormulaComponents = formula,
|
||||
FormulaVersion = spell.FormulaVersion,
|
||||
ComponentLoss = spell.ComponentLoss,
|
||||
BaseRangeConstant = spell.BaseRangeConstant,
|
||||
BaseRangeModifier = spell.BaseRangeMod,
|
||||
SpellEconomyModifier = spell.SpellEconomyMod,
|
||||
FizzleEffect = (uint)spell.FizzleEffect,
|
||||
RecoveryInterval = spell.RecoveryInterval,
|
||||
RecoveryAmount = spell.RecoveryAmount,
|
||||
NonComponentTargetType = (uint)spell.NonComponentTargetType,
|
||||
FormulaTargetType = formulaTargetType,
|
||||
ManaModifier = spell.ManaMod,
|
||||
DegradeModifier = spell.DegradeModifier,
|
||||
DegradeLimit = spell.DegradeLimit,
|
||||
PortalLifetime = spell.PortalLifetime,
|
||||
};
|
||||
}
|
||||
|
||||
private static string SchoolName(DatReaderWriter.Enums.MagicSchool school) => school switch
|
||||
{
|
||||
DatReaderWriter.Enums.MagicSchool.WarMagic => "War Magic",
|
||||
DatReaderWriter.Enums.MagicSchool.LifeMagic => "Life Magic",
|
||||
DatReaderWriter.Enums.MagicSchool.ItemEnchantment => "Item Enchantment",
|
||||
DatReaderWriter.Enums.MagicSchool.CreatureEnchantment => "Creature Enchantment",
|
||||
DatReaderWriter.Enums.MagicSchool.VoidMagic => "Void Magic",
|
||||
_ => "None",
|
||||
};
|
||||
|
||||
private static CoreMagicSchool ToCoreSchool(DatReaderWriter.Enums.MagicSchool school) => school switch
|
||||
{
|
||||
DatReaderWriter.Enums.MagicSchool.WarMagic => CoreMagicSchool.WarMagic,
|
||||
DatReaderWriter.Enums.MagicSchool.LifeMagic => CoreMagicSchool.LifeMagic,
|
||||
DatReaderWriter.Enums.MagicSchool.ItemEnchantment => CoreMagicSchool.ItemEnchantment,
|
||||
DatReaderWriter.Enums.MagicSchool.CreatureEnchantment => CoreMagicSchool.CreatureEnchantment,
|
||||
DatReaderWriter.Enums.MagicSchool.VoidMagic => CoreMagicSchool.VoidMagic,
|
||||
_ => CoreMagicSchool.None,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// ACE's independently ported <c>SpellComponentsTable.GetSpellWords</c>:
|
||||
/// Herb supplies word one; Powder + lower-cased Potion form word two.
|
||||
/// </summary>
|
||||
private static string BuildSpellWords(
|
||||
IReadOnlyList<uint> formula,
|
||||
SpellComponentTable? componentTable)
|
||||
{
|
||||
if (componentTable is null) return string.Empty;
|
||||
|
||||
string first = string.Empty;
|
||||
string second = string.Empty;
|
||||
string third = string.Empty;
|
||||
foreach (uint componentId in formula)
|
||||
{
|
||||
if (!componentTable.Components.TryGetValue(componentId, out SpellComponentBase? component))
|
||||
continue;
|
||||
|
||||
switch (component.Type)
|
||||
{
|
||||
case ComponentType.Herb:
|
||||
first = component.Text.Value;
|
||||
break;
|
||||
case ComponentType.Powder:
|
||||
second = component.Text.Value;
|
||||
break;
|
||||
case ComponentType.Potion:
|
||||
third = component.Text.Value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
string tail = second + third.ToLower(CultureInfo.InvariantCulture);
|
||||
if (tail.Length != 0)
|
||||
tail = char.ToUpperInvariant(tail[0]) + tail[1..];
|
||||
return $"{first} {tail}".Trim();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue