feat(headless): share immutable gameplay content
This commit is contained in:
parent
12b500d383
commit
9569dadb57
25 changed files with 1031 additions and 429 deletions
|
|
@ -392,7 +392,7 @@ public sealed class GameWindow :
|
|||
private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
|
||||
private AcDream.App.World.ExternalContainerLifecycleController? _externalContainerLifecycle;
|
||||
private AcDream.App.Spells.MagicRuntime? _magicRuntime;
|
||||
private AcDream.App.Spells.MagicCatalog? _magicCatalog;
|
||||
private MagicCatalog? _magicCatalog;
|
||||
private readonly AcDream.Core.Items.StackSplitQuantityState _stackSplitQuantity = new();
|
||||
// Phase D.2b Sub-phase C Slice 2 — the 3-D doll viewport: an off-screen RTT renderer, the UiViewport
|
||||
// widget that blits it, the inventory frame (for the open-gate), and a dirty flag (re-dress on 0xF625).
|
||||
|
|
@ -750,7 +750,7 @@ public sealed class GameWindow :
|
|||
"prepared asset source");
|
||||
|
||||
void IGameWindowContentEffectsAudioPublication.PublishMagicCatalog(
|
||||
AcDream.App.Spells.MagicCatalog value) =>
|
||||
MagicCatalog value) =>
|
||||
PublishCompositionOwner(ref _magicCatalog, value, "magic catalog");
|
||||
|
||||
void IGameWindowContentEffectsAudioPublication.PublishAnimationLoader(
|
||||
|
|
|
|||
|
|
@ -1,163 +1,18 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using DatReaderWriter;
|
||||
using AcDream.Content;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using CoreSpellTable = AcDream.Core.Spells.SpellTable;
|
||||
|
||||
namespace AcDream.App.Spells;
|
||||
|
||||
/// <summary>Immutable presentation metadata for one retail spell component WCID.</summary>
|
||||
public sealed record SpellComponentDescriptor(
|
||||
uint WeenieClassId,
|
||||
string Name,
|
||||
uint Category,
|
||||
uint IconId);
|
||||
|
||||
/// <summary>One resolved formula cell in retail's spell examination subview.</summary>
|
||||
public readonly record struct SpellExamineComponent(
|
||||
uint SpellComponentId,
|
||||
SpellComponentDescriptor Descriptor,
|
||||
bool Owned);
|
||||
|
||||
/// <summary>
|
||||
/// App-layer projection of retail's spell, component, and school-focus DAT tables.
|
||||
/// Keeping this catalog outside <c>GameWindow</c> makes the SCID/WCID boundary and
|
||||
/// rough spell-level calculation one independently testable ownership seam.
|
||||
/// </summary>
|
||||
public sealed class MagicCatalog
|
||||
{
|
||||
private const uint SpellTableDid = 0x0E00000Eu;
|
||||
private const uint SpellComponentTableDid = 0x0E00000Fu;
|
||||
private const uint ComponentIdMapDid = 0x27000002u;
|
||||
|
||||
private readonly IReadOnlyDictionary<uint, SpellFormulaDefinition> _formulaDefinitions;
|
||||
private readonly IReadOnlyDictionary<uint, uint> _wcidByScid;
|
||||
private readonly IReadOnlyDictionary<uint, uint> _magicPackWcidBySchool;
|
||||
private readonly IReadOnlyDictionary<uint, int> _spellLevels;
|
||||
|
||||
private MagicCatalog(
|
||||
CoreSpellTable spellTable,
|
||||
IReadOnlyDictionary<uint, SpellComponentDescriptor> components,
|
||||
IReadOnlyDictionary<uint, SpellFormulaDefinition> formulaDefinitions,
|
||||
IReadOnlyDictionary<uint, uint> wcidByScid,
|
||||
IReadOnlyDictionary<uint, uint> magicPackWcidBySchool,
|
||||
IReadOnlyDictionary<uint, int> spellLevels)
|
||||
{
|
||||
SpellTable = spellTable;
|
||||
Components = components;
|
||||
_formulaDefinitions = formulaDefinitions;
|
||||
_wcidByScid = wcidByScid;
|
||||
_magicPackWcidBySchool = magicPackWcidBySchool;
|
||||
_spellLevels = spellLevels;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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 = AcDream.App.UI.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,
|
||||
formulaDefinitions,
|
||||
wcidByScid,
|
||||
magicPackWcidBySchool,
|
||||
spellLevels);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// App content/policy projection for Runtime's live retail magic request
|
||||
/// owner. The server owns turning, motion, fizzle, mana/component consumption,
|
||||
|
|
|
|||
|
|
@ -1,140 +0,0 @@
|
|||
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.App.Spells;
|
||||
|
||||
/// <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>
|
||||
internal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.App.Spells;
|
||||
|
||||
public sealed record SpellFormulaDefinition(
|
||||
uint FormulaVersion,
|
||||
IReadOnlyList<uint> ComponentIds,
|
||||
uint School = 0u);
|
||||
|
||||
/// <summary>
|
||||
/// Client-side retail component preflight for ClientMagicSystem::CastSpell
|
||||
/// (0x00568040). Spell formula entries are SCIDs; carried inventory objects and
|
||||
/// SetDesiredComponent use WCIDs, so all comparisons cross the DAT enum map.
|
||||
/// </summary>
|
||||
public sealed class SpellComponentRequirementService
|
||||
{
|
||||
public const uint SpellComponentsRequiredProperty = 68u;
|
||||
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly Func<uint> _playerGuid;
|
||||
private readonly Func<string> _accountName;
|
||||
private readonly IReadOnlyDictionary<uint, SpellFormulaDefinition> _formulas;
|
||||
private readonly IReadOnlyDictionary<uint, uint> _wcidByScid;
|
||||
private readonly IReadOnlyDictionary<uint, uint> _magicPackWcidBySchool;
|
||||
|
||||
public SpellComponentRequirementService(
|
||||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
Func<string> accountName,
|
||||
IReadOnlyDictionary<uint, SpellFormulaDefinition> formulas,
|
||||
IReadOnlyDictionary<uint, uint> wcidByScid,
|
||||
IReadOnlyDictionary<uint, uint>? magicPackWcidBySchool = null)
|
||||
{
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||
_accountName = accountName ?? throw new ArgumentNullException(nameof(accountName));
|
||||
_formulas = formulas ?? throw new ArgumentNullException(nameof(formulas));
|
||||
_wcidByScid = wcidByScid ?? throw new ArgumentNullException(nameof(wcidByScid));
|
||||
_magicPackWcidBySchool = magicPackWcidBySchool
|
||||
?? new Dictionary<uint, uint>();
|
||||
}
|
||||
|
||||
public bool HasRequiredComponents(uint spellId)
|
||||
{
|
||||
uint playerGuid = _playerGuid();
|
||||
ClientObject? player = _objects.Get(playerGuid);
|
||||
if (player is not null
|
||||
&& !player.Properties.GetBool(SpellComponentsRequiredProperty, true))
|
||||
return true;
|
||||
if (!_formulas.TryGetValue(spellId, out SpellFormulaDefinition? formula))
|
||||
return false;
|
||||
|
||||
HashSet<uint> ownedWcids = _objects.Objects
|
||||
.Where(item => IsOwnedByPlayer(item, playerGuid))
|
||||
.Select(item => item.WeenieClassId)
|
||||
.ToHashSet();
|
||||
IReadOnlyList<uint> components = GetAppropriateFormula(
|
||||
formula, player, playerGuid);
|
||||
foreach (uint scid in components)
|
||||
{
|
||||
if (scid == 0u) continue;
|
||||
if (!_wcidByScid.TryGetValue(scid, out uint wcid)
|
||||
|| !ownedWcids.Contains(wcid))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>ClientMagicSystem::GetAppropriateSpellFormula</c>
|
||||
/// (0x00567D50): an infused school or a directly carried school focus uses
|
||||
/// the scarab-only formula; otherwise the account-customized formula wins.
|
||||
/// ACE characters with component enforcement disabled use the same modern
|
||||
/// scarab/taper presentation instead of exposing an unusable legacy recipe.
|
||||
/// </summary>
|
||||
public IReadOnlyList<uint> GetAppropriateFormula(uint spellId)
|
||||
{
|
||||
if (!_formulas.TryGetValue(spellId, out SpellFormulaDefinition? formula))
|
||||
return [];
|
||||
uint playerGuid = _playerGuid();
|
||||
return GetAppropriateFormula(
|
||||
formula,
|
||||
_objects.Get(playerGuid),
|
||||
playerGuid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the component tracker would leave a formula icon unghosted.
|
||||
/// The input is a retail spell-component id (SCID), not a weenie class id.
|
||||
/// </summary>
|
||||
public bool IsComponentOwned(uint spellComponentId)
|
||||
{
|
||||
if (!_wcidByScid.TryGetValue(spellComponentId, out uint weenieClassId))
|
||||
return false;
|
||||
uint playerGuid = _playerGuid();
|
||||
return _objects.Objects.Any(item =>
|
||||
item.WeenieClassId == weenieClassId
|
||||
&& IsOwnedByPlayer(item, playerGuid));
|
||||
}
|
||||
|
||||
private IReadOnlyList<uint> GetAppropriateFormula(
|
||||
SpellFormulaDefinition formula,
|
||||
ClientObject? player,
|
||||
uint playerGuid)
|
||||
{
|
||||
bool componentsRequired =
|
||||
player?.Properties.GetBool(SpellComponentsRequiredProperty, true)
|
||||
?? true;
|
||||
bool infused = SchoolInfusionProperty(formula.School) is uint propertyId
|
||||
&& player?.Properties.GetInt(propertyId) > 0;
|
||||
bool carriesMagicPack = _magicPackWcidBySchool.TryGetValue(
|
||||
formula.School, out uint packWcid)
|
||||
&& _objects.Objects.Any(item =>
|
||||
item.ContainerId == playerGuid
|
||||
&& item.WeenieClassId == packWcid);
|
||||
return !componentsRequired || infused || carriesMagicPack
|
||||
? RetailSpellFormula.InqScarabOnlyFormula(formula.ComponentIds)
|
||||
: RetailSpellFormula.CustomizeForAccount(
|
||||
formula.ComponentIds, formula.FormulaVersion, _accountName());
|
||||
}
|
||||
|
||||
private static uint? SchoolInfusionProperty(uint school) => school switch
|
||||
{
|
||||
1u => 0x129u, // AugmentationInfusedWarMagic
|
||||
2u => 0x128u, // AugmentationInfusedLifeMagic
|
||||
3u => 0x127u, // AugmentationInfusedItemMagic
|
||||
4u => 0x126u, // AugmentationInfusedCreatureMagic
|
||||
5u => 0x148u, // AugmentationInfusedVoidMagic
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private bool IsOwnedByPlayer(ClientObject item, uint playerGuid)
|
||||
{
|
||||
if (playerGuid == 0u) return false;
|
||||
ClientObject current = item;
|
||||
for (int depth = 0; depth < 16; depth++)
|
||||
{
|
||||
if (current.WielderId == playerGuid || current.ContainerId == playerGuid)
|
||||
return true;
|
||||
if (current.ContainerId == 0u
|
||||
|| _objects.Get(current.ContainerId) is not { } parent)
|
||||
return false;
|
||||
current = parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
using System.Collections.Immutable;
|
||||
using DatReaderWriter;
|
||||
using AcDream.Content;
|
||||
|
||||
|
|
@ -102,7 +101,9 @@ public sealed class LandblockBuildFactory
|
|||
}
|
||||
|
||||
AcDream.Core.World.LandblockCollisionBuild collisions =
|
||||
BuildPreparedCollisionClosure(build.Landblock);
|
||||
LandblockPhysicsContentBuilder.BuildPreparedCollisionClosure(
|
||||
_preparedCollisions,
|
||||
build.Landblock);
|
||||
AcDream.Core.World.PhysicsDatBundle publicationDats =
|
||||
(build.Landblock.PhysicsDats
|
||||
?? AcDream.Core.World.PhysicsDatBundle.Empty)
|
||||
|
|
@ -155,59 +156,11 @@ public sealed class LandblockBuildFactory
|
|||
// expand into per-part MeshRefs via SetupMesh.Flatten.
|
||||
// GPU upload happens later through the render-thread presentation
|
||||
// pipeline, never in this worker-side build.
|
||||
var hydrated = new List<AcDream.Core.World.WorldEntity>(baseLoaded.Entities.Count);
|
||||
foreach (var e in baseLoaded.Entities)
|
||||
{
|
||||
var meshRefs = new List<AcDream.Core.World.MeshRef>();
|
||||
var stabBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator();
|
||||
|
||||
if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x01000000u)
|
||||
{
|
||||
// Single GfxObj stab — identity part transform.
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(e.SourceGfxObjOrSetupId);
|
||||
if (gfx is not null)
|
||||
{
|
||||
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
|
||||
if (pb is not null) stabBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value);
|
||||
meshRefs.Add(new AcDream.Core.World.MeshRef(
|
||||
e.SourceGfxObjOrSetupId, System.Numerics.Matrix4x4.Identity));
|
||||
}
|
||||
}
|
||||
else if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
|
||||
{
|
||||
// Multi-part Setup — flatten to per-part GfxObj refs.
|
||||
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
|
||||
if (setup is not null)
|
||||
{
|
||||
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
|
||||
foreach (var mr in flat)
|
||||
{
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
|
||||
if (gfx is null) continue;
|
||||
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
|
||||
if (pb is not null) stabBounds.Add(mr.PartTransform, pb.Value);
|
||||
meshRefs.Add(mr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (meshRefs.Count == 0) continue;
|
||||
|
||||
var entity = new AcDream.Core.World.WorldEntity
|
||||
{
|
||||
Id = e.Id,
|
||||
SourceGfxObjOrSetupId = e.SourceGfxObjOrSetupId,
|
||||
Position = e.Position + worldOffset,
|
||||
Rotation = e.Rotation,
|
||||
MeshRefs = meshRefs,
|
||||
EffectCellId = e.EffectCellId,
|
||||
IsBuildingShell = e.IsBuildingShell, // Phase A8: preserve dat-level tag
|
||||
BuildingShellAnchorCellId = e.BuildingShellAnchorCellId,
|
||||
};
|
||||
if (stabBounds.TryGet(out var sbMin, out var sbMax))
|
||||
entity.SetLocalBounds(sbMin, sbMax);
|
||||
hydrated.Add(entity);
|
||||
}
|
||||
IReadOnlyList<AcDream.Core.World.WorldEntity> hydrated =
|
||||
LandblockPhysicsContentBuilder.HydrateStaticEntities(
|
||||
_dats,
|
||||
baseLoaded,
|
||||
worldOffset);
|
||||
|
||||
// Task 8: merge stabs + scenery + interior into one entity list.
|
||||
var merged = new List<AcDream.Core.World.WorldEntity>(hydrated);
|
||||
|
|
@ -226,7 +179,10 @@ public sealed class LandblockBuildFactory
|
|||
|
||||
// Pre-read every DAT object publication consumes. Build this after
|
||||
// `merged` so all entity GfxObj/Setup ids are known.
|
||||
var physicsDats = BuildPhysicsDatBundle(landblockId, merged);
|
||||
var physicsDats = LandblockPhysicsContentBuilder.BuildDatBundle(
|
||||
_dats,
|
||||
landblockId,
|
||||
merged);
|
||||
var completedEnvCells = envCellBuild.Build();
|
||||
return new AcDream.App.Streaming.LandblockBuild(
|
||||
new AcDream.Core.World.LoadedLandblock(
|
||||
|
|
@ -237,180 +193,6 @@ public sealed class LandblockBuildFactory
|
|||
completedEnvCells,
|
||||
request.Origin);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Pre-reads every DAT object that landblock publication consumes, so the
|
||||
/// update thread performs no DAT reads. Gather and publication must
|
||||
/// enumerate the same identifiers. Reads only; no runtime registration.
|
||||
/// </summary>
|
||||
private AcDream.Core.World.PhysicsDatBundle BuildPhysicsDatBundle(
|
||||
uint landblockId,
|
||||
System.Collections.Generic.IReadOnlyList<AcDream.Core.World.WorldEntity> entities)
|
||||
{
|
||||
var envCells = new System.Collections.Generic.Dictionary<uint, DatReaderWriter.DBObjs.EnvCell>();
|
||||
var environments = new System.Collections.Generic.Dictionary<uint, DatReaderWriter.DBObjs.Environment>();
|
||||
var setups = new System.Collections.Generic.Dictionary<uint, DatReaderWriter.DBObjs.Setup>();
|
||||
var gfxObjs = new System.Collections.Generic.Dictionary<uint, DatReaderWriter.DBObjs.GfxObj>();
|
||||
|
||||
// (1) LandBlockInfo
|
||||
var lbInfo = _dats!.Get<DatReaderWriter.DBObjs.LandBlockInfo>(
|
||||
(landblockId & 0xFFFF0000u) | 0xFFFEu);
|
||||
|
||||
if (lbInfo is not null)
|
||||
{
|
||||
// (2)+(3) EnvCell + Environment, per cell
|
||||
if (lbInfo.NumCells > 0)
|
||||
{
|
||||
uint firstCellId = (landblockId & 0xFFFF0000u) | 0x0100u;
|
||||
for (uint offset = 0; offset < lbInfo.NumCells; offset++)
|
||||
{
|
||||
uint envCellId = firstCellId + offset;
|
||||
var envCell = _dats.Get<DatReaderWriter.DBObjs.EnvCell>(envCellId);
|
||||
if (envCell is null) continue;
|
||||
envCells[envCellId] = envCell;
|
||||
if (envCell.EnvironmentId == 0) continue;
|
||||
uint envId = 0x0D000000u | envCell.EnvironmentId;
|
||||
if (!environments.ContainsKey(envId))
|
||||
{
|
||||
var environment = _dats.Get<DatReaderWriter.DBObjs.Environment>(envId);
|
||||
if (environment is not null) environments[envId] = environment;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (4) building shell Setup, per building
|
||||
foreach (var building in lbInfo.Buildings)
|
||||
{
|
||||
uint modelId = building.ModelId;
|
||||
if ((modelId & 0xFF000000u) == 0x02000000u && !setups.ContainsKey(modelId))
|
||||
{
|
||||
var bldSetup = _dats.Get<DatReaderWriter.DBObjs.Setup>(modelId);
|
||||
if (bldSetup is not null) setups[modelId] = bldSetup;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (5)+(6) entity GfxObj (BSP cache) + entity Setup (ShadowObjects parts/lights)
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
uint src = entity.SourceGfxObjOrSetupId;
|
||||
if ((src & 0xFF000000u) == 0x02000000u && !setups.ContainsKey(src))
|
||||
{
|
||||
var s = _dats.Get<DatReaderWriter.DBObjs.Setup>(src);
|
||||
if (s is not null) setups[src] = s;
|
||||
}
|
||||
foreach (var meshRef in entity.MeshRefs)
|
||||
{
|
||||
uint gid = meshRef.GfxObjId;
|
||||
if ((gid & 0xFF000000u) != 0x01000000u) continue;
|
||||
if (gfxObjs.ContainsKey(gid)) continue;
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(gid);
|
||||
if (gfx is not null) gfxObjs[gid] = gfx;
|
||||
}
|
||||
}
|
||||
|
||||
return new AcDream.Core.World.PhysicsDatBundle(lbInfo, envCells, environments, setups, gfxObjs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the exact prepared collision closure after the serialized DAT
|
||||
/// transaction completes. The immutable package is independently
|
||||
/// thread-safe, so these reads never extend the shared DAT-reader lock.
|
||||
/// Missing or corrupt prepared data fails the complete near-tier build;
|
||||
/// production must not publish a graph-only partial generation.
|
||||
/// </summary>
|
||||
private AcDream.Core.World.LandblockCollisionBuild
|
||||
BuildPreparedCollisionClosure(
|
||||
AcDream.Core.World.LoadedLandblock landblock)
|
||||
{
|
||||
AcDream.Core.World.PhysicsDatBundle dats =
|
||||
landblock.PhysicsDats ??
|
||||
AcDream.Core.World.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,
|
||||
AcDream.Core.Physics.FlatGfxObjCollisionAsset>();
|
||||
var setups =
|
||||
ImmutableDictionary.CreateBuilder<
|
||||
uint,
|
||||
AcDream.Core.Physics.FlatSetupCollision>();
|
||||
var cellStructures =
|
||||
ImmutableDictionary.CreateBuilder<
|
||||
uint,
|
||||
AcDream.Core.Physics.FlatCellStructureCollisionAsset>();
|
||||
var envCells =
|
||||
ImmutableDictionary.CreateBuilder<
|
||||
uint,
|
||||
AcDream.Core.Physics.FlatEnvCellTopology>();
|
||||
|
||||
foreach (uint id in gfxObjIds)
|
||||
{
|
||||
gfxObjs.Add(
|
||||
id,
|
||||
RequirePrepared(
|
||||
_preparedCollisions.ReadGfxObjCollision(id),
|
||||
"GfxObj collision",
|
||||
id));
|
||||
}
|
||||
|
||||
foreach (uint id in setupIds)
|
||||
{
|
||||
setups.Add(
|
||||
id,
|
||||
RequirePrepared(
|
||||
_preparedCollisions.ReadSetupCollision(id),
|
||||
"Setup collision",
|
||||
id));
|
||||
}
|
||||
|
||||
foreach (uint id in envCellIds)
|
||||
{
|
||||
cellStructures.Add(
|
||||
id,
|
||||
RequirePrepared(
|
||||
_preparedCollisions.ReadCellStructureCollision(id),
|
||||
"CellStruct collision",
|
||||
id));
|
||||
envCells.Add(
|
||||
id,
|
||||
RequirePrepared(
|
||||
_preparedCollisions.ReadEnvCellTopology(id),
|
||||
"EnvCell topology",
|
||||
id));
|
||||
}
|
||||
|
||||
return new AcDream.Core.World.LandblockCollisionBuild(
|
||||
gfxObjs.ToImmutable(),
|
||||
setups.ToImmutable(),
|
||||
cellStructures.ToImmutable(),
|
||||
envCells.ToImmutable(),
|
||||
gfxObjIds,
|
||||
setupIds,
|
||||
envCellIds);
|
||||
}
|
||||
|
||||
private static T RequirePrepared<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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase A.1 Task 8: generate scenery (trees, rocks, bushes) for a single
|
||||
/// landblock on the worker thread. Pure CPU — no GL calls.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using AcDream.App.Spells;
|
|||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Content;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.Ui;
|
||||
using AcDream.Content;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
using DatReaderWriter;
|
||||
using AcDream.Content;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue