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
4 changes: 3 additions & 1 deletion AdvancedPortals/AdvancedPortals.cs
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,9 @@ private void Awake()
{
_harmony.Patch(handlePortalClickMethod, new HarmonyMethod(
typeof(Teleport_Patch), nameof(Teleport_Patch.TargetPortal_HandlePortalClick_Prefix)));
_harmony.Patch(handlePortalClickMethod, null, new HarmonyMethod(
// Finalizer, not postfix: must clear CurrentAdvancedPortal even when the click
// handler throws, or the stuck static applies this portal's allow-list globally.
_harmony.Patch(handlePortalClickMethod, finalizer: new HarmonyMethod(
typeof(Teleport_Patch), nameof(Teleport_Patch.Generic_Postfix)));
}
}
Expand Down
23 changes: 17 additions & 6 deletions AdvancedPortals/src/Teleport_Patch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ public static class Teleport_Patch

public static void TargetPortal_HandlePortalClick_Prefix()
{
if (Player.m_localPlayer == null)
{
return;
}
Vector3 playerPos = Player.m_localPlayer.transform.position;
const float searchRadius = 2.0f;
Collider[] colliders = Physics.OverlapSphere(playerPos, searchRadius);
Expand Down Expand Up @@ -61,9 +65,13 @@ public static void TeleportWorld_UpdatePortal_Prefix(TeleportWorld __instance)
CurrentAdvancedPortal = __instance.GetComponent<AdvancedPortal>();
}

// Finalizer, not postfix: UpdatePortal runs on an InvokeRepeating timer for every portal in the
// scene, and a postfix does not run when the original (or another mod's patch) throws -- which
// used to leave CurrentAdvancedPortal stuck, applying one portal's allow-list to every later
// Inventory.IsTeleportable call in the game.
[HarmonyPatch(typeof(TeleportWorld), nameof(TeleportWorld.UpdatePortal))]
[HarmonyPostfix]
public static void TeleportWorld_UpdatePortal_Postfix()
[HarmonyFinalizer]
public static void TeleportWorld_UpdatePortal_Finalizer()
{
CurrentAdvancedPortal = null;
}
Expand All @@ -76,8 +84,8 @@ public static void TeleportWorld_Teleport_Prefix(TeleportWorld __instance)
}

[HarmonyPatch(typeof(TeleportWorld), nameof(TeleportWorld.Teleport))]
[HarmonyPostfix]
public static void TeleportWorld_Teleport_Postfix()
[HarmonyFinalizer]
public static void TeleportWorld_Teleport_Finalizer()
{
CurrentAdvancedPortal = null;
}
Expand All @@ -102,12 +110,15 @@ public static void Inventory_IsTeleportable_Pretfix(Inventory __instance, ref bo

foreach (ItemDrop.ItemData itemData in __instance.GetAllItems())
{
if (itemData.m_dropPrefab == null)
if (itemData.m_shared.m_teleportable)
{
continue;
}

if (!itemData.m_shared.m_teleportable &&
// A non-teleportable item with no drop prefab cannot be matched against the allow-list;
// treat it as blocking rather than silently letting it through (vanilla blocks on the
// shared flag alone and never consults the prefab).
if (itemData.m_dropPrefab == null ||
!CurrentAdvancedPortal.AllowedItems.Contains(itemData.m_dropPrefab.name))
{
return;
Expand Down
24 changes: 22 additions & 2 deletions AdvancedPortals/src/UpdatePortals.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ public static void UpdatePortalConfigurations()
if (piece == null)
{
AdvancedPortals.APLogger.LogError($"Could not update portal configurations for {portal}. Piece not found.");
continue;
}

GameObject pieceTablePiece = pieceTable.m_pieces.Find(x => x.name == Utils.GetPrefabName(portalPrefab.name));
Expand All @@ -142,6 +143,13 @@ public static void UpdatePortalConfigurations()
{
var newReq = req.GetRequirement();
GameObject resource = PrefabManager.Instance.GetPrefab(req.Item);
if (resource == null)
{
// A typo or an item from an uninstalled mod: skip this requirement instead of
// NRE-ing out of OnPiecesRegistered and leaving the remaining portals unconfigured.
AdvancedPortals.APLogger.LogError($"Could not find requirement prefab {req.Item}, for {portal}");
continue;
}
ItemDrop resourceItemDrop = resource.GetComponent<ItemDrop>();
if (resourceItemDrop != null)
{
Expand All @@ -154,14 +162,26 @@ public static void UpdatePortalConfigurations()
}
}

piece.m_resources = reqs.ToArray();
// If every configured ingredient failed to resolve, keep the previous requirements:
// an empty array would make the portal free to build.
if (reqs.Count > 0)
{
piece.m_resources = reqs.ToArray();
}
else
{
AdvancedPortals.APLogger.LogError($"No valid ingredients resolved for {portal}; keeping the previous build cost.");
}
piece.m_description = GetAdvancedPortalDescription(component.AllowEverything, component.AllowedItems);

if (pieceTablePiece != null)
{
// Update existing
var tablePiece = pieceTablePiece.GetComponent<Piece>();
tablePiece.m_resources = reqs.ToArray();
if (reqs.Count > 0)
{
tablePiece.m_resources = reqs.ToArray();
}
}
else
{
Expand Down
25 changes: 19 additions & 6 deletions Common/src/Modding/AssetBundleLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ namespace Common {
/// caller can be another type in this shared project rather than the mod, so the assembly is explicit.
/// </summary>
public static class AssetBundleLoader {
// AssetBundle.LoadFromStream reads asset data lazily from the managed stream, so the stream must
// outlive the bundle. Disposing it (the old `using` here) made later LoadAsset calls fail or throw
// ObjectDisposedException depending on timing. Bundles are loaded once per mod and live for the
// process, so the streams are simply kept alive here.
private static readonly System.Collections.Generic.List<Stream> _liveStreams =
new System.Collections.Generic.List<Stream>();

/// <summary>
/// Loads the bundle embedded as "&lt;assembly name&gt;.&lt;filename&gt;". Returns null (and logs) on failure.
/// </summary>
Expand All @@ -20,13 +27,19 @@ public static AssetBundle LoadFromResources(string filename, Assembly assembly)

string resourceName = $"{assembly.GetName().Name}.{filename}";
try {
using (Stream stream = assembly.GetManifestResourceStream(resourceName)) {
if (stream == null) {
ModLogger.LogError($"Embedded asset bundle '{resourceName}' not found in {assembly.GetName().Name}.");
return null;
}
return AssetBundle.LoadFromStream(stream);
Stream stream = assembly.GetManifestResourceStream(resourceName);
if (stream == null) {
ModLogger.LogError($"Embedded asset bundle '{resourceName}' not found in {assembly.GetName().Name}.");
return null;
}
AssetBundle bundle = AssetBundle.LoadFromStream(stream);
if (bundle == null) {
stream.Dispose();
ModLogger.LogError($"Embedded asset bundle '{resourceName}' failed to load.");
return null;
}
_liveStreams.Add(stream);
return bundle;
} catch (Exception e) {
ModLogger.LogError($"Failed to load embedded asset bundle '{resourceName}': {e.Message}");
return null;
Expand Down
14 changes: 10 additions & 4 deletions Common/src/Modding/ConfigDrawers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,15 +120,21 @@ private static float DrawSliderRow(string label, object key, float current, floa
float slid = GUILayout.HorizontalSlider(shown, min, max, GUILayout.Width(150f));
if (Mathf.Abs(slid - shown) > Mathf.Epsilon) { SliderPending[key] = slid; }
if (SliderPending.TryGetValue(key, out float dragging)) {
result = dragging;
if (!Input.GetMouseButton(0)) { SliderPending.Remove(key); }
// Deferred commit: while the mouse is held, only the pending display value changes.
// Returning the dragged value every frame wrote cfg.Value (and with SaveOnConfigSet, the
// .cfg file) once per frame of the drag -- commit exactly once, on release.
if (!Input.GetMouseButton(0)) {
SliderPending.Remove(key);
result = dragging;
}
}

// Numeric text box for exact entry.
// Numeric text box for exact entry. Shows the in-flight drag value while one exists.
float uiValue = SliderPending.TryGetValue(key, out float pendingDrag) ? pendingDrag : result;
string ctrl = "num_" + key.GetHashCode();
GUI.SetNextControlName(ctrl);
bool focused = GUI.GetNameOfFocusedControl() == ctrl;
string live = isInt ? Mathf.RoundToInt(result).ToString() : result.ToString("0.###");
string live = isInt ? Mathf.RoundToInt(uiValue).ToString() : uiValue.ToString("0.###");
string shownText = focused && TextBuffer.TryGetValue(key, out string buf) ? buf : live;
string typed = GUILayout.TextField(shownText, GUILayout.Width(70f));
if (focused) {
Expand Down
54 changes: 38 additions & 16 deletions Common/src/Modding/ItemBatchLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,10 @@ public bool BatchSetup(AssetBundle assetBundle = null, bool reverse_order = true
}
WireConfigDefs();

bool on_server = ZNet.instance != null && Common.Utils.IsServer();
// BatchSetup runs from plugin Awake, before any world exists -- ZNet.instance is always null
// here. The headless graphics-device check inside Utils.IsServer is what actually detects a
// dedicated server this early.
bool on_server = Common.Utils.IsServer();

if (on_server == false) {
// This is not needed on the server
Expand All @@ -101,6 +104,14 @@ public bool BatchSetup(AssetBundle assetBundle = null, bool reverse_order = true

private static bool WireConfigDefs() {
foreach (ItemDefinition itemdef in resourceDefinitions) {
// A definition registered without a Recipe (or with a null item list) used to NRE here,
// killing the whole consumer mod during Awake. Normalize instead: the item is still
// registered, it just gets no crafting recipe (see BatchAddItems).
if (itemdef.Recipe == null) { itemdef.Recipe = new RecipeDefinition(); }
if (itemdef.Recipe.RecipeItems == null) {
ModLogger.LogError($"Item definition '{itemdef.Name}' has no recipe items; it will be registered without a crafting recipe.");
itemdef.Recipe.RecipeItems = new List<RecipeIngredient>();
}
// Build a compacted display name for reference, this primarily just needs spaces removed.
itemdef.DisplayName = string.Join("", itemdef.Name.Split(default(string[]), StringSplitOptions.RemoveEmptyEntries));
// Blow up if adding a non-unique data control
Expand Down Expand Up @@ -280,20 +291,28 @@ private static bool BatchAddItems() {
SetItemDamageModifier(modifier, dmgmod.Key, ItemD.m_itemData);
}
}
ItemConfig itemcfg = new ItemConfig() {
Amount = itemdef.CraftAmountCfg.Value,
CraftingStation = $"{itemdef.CraftedAtCfg.Value}",
MinStationLevel = itemdef.StationLVLCfg.Value,
// Always register as enabled so the recipe is added to and retained in the ObjectDB (a
// recipe registered disabled never gets cached/retained). The real craftable state is
// applied immediately after by ReapplyAllRecipeConfig -> EnableDisableItemInDB, so a
// disabled item still lives in the DB (m_enabled=false), stays modifiable, and re-enables
// correctly - including after a server ObjectDB copy replaces the recipe list.
Enabled = true,
Icons = ItemSprite != null ? new[] { ItemSprite } : null,
Requirements = itemdef.Recipe.RecipeReqs.ToArray()
};
ItemManager.Instance.AddItem(new CustomItem(ItemPrefab, FixReferences, itemcfg));
if (itemdef.Recipe.RecipeReqs == null || itemdef.Recipe.RecipeReqs.Count == 0) {
// No valid requirements resolved (empty/missing recipe definition and no usable config
// value). Registering a recipe with zero requirements would make the item free to craft,
// so register the item without one.
ModLogger.LogError($"Item '{itemdef.Name}' has no valid recipe requirements; registering it without a crafting recipe.");
ItemManager.Instance.AddItem(new CustomItem(ItemPrefab, FixReferences));
} else {
ItemConfig itemcfg = new ItemConfig() {
Amount = itemdef.CraftAmountCfg.Value,
CraftingStation = $"{itemdef.CraftedAtCfg.Value}",
MinStationLevel = itemdef.StationLVLCfg.Value,
// Always register as enabled so the recipe is added to and retained in the ObjectDB (a
// recipe registered disabled never gets cached/retained). The real craftable state is
// applied immediately after by ReapplyAllRecipeConfig -> EnableDisableItemInDB, so a
// disabled item still lives in the DB (m_enabled=false), stays modifiable, and re-enables
// correctly - including after a server ObjectDB copy replaces the recipe list.
Enabled = true,
Icons = ItemSprite != null ? new[] { ItemSprite } : null,
Requirements = itemdef.Recipe.RecipeReqs.ToArray()
};
ItemManager.Instance.AddItem(new CustomItem(ItemPrefab, FixReferences, itemcfg));
}

// This item needs to be included as a returnable arrow/bolt
if (itemdef.Category == ItemCategory.Arrows) {
Expand Down Expand Up @@ -667,7 +686,10 @@ private static IEnumerator DrainWorldUpdates() {
if (go == null) { continue; }
if (!go.TryGetComponent<ItemDrop>(out ItemDrop id)) { continue; }
foreach (KeyValuePair<string, Action<ItemDrop.ItemData>> update in pendingWorldUpdates) {
if (go.name.StartsWith(update.Key)) {
// Exact match (plus the runtime clone suffix): StartsWith would also hit prefabs
// that merely share the prefix ("ArrowWood" -> "ArrowWoodFire"), and shared data
// means that silently rewrites the other item's stats.
if (go.name == update.Key || go.name == update.Key + "(Clone)") {
update.Value(id.m_itemData);
}
}
Expand Down
8 changes: 8 additions & 0 deletions Common/src/Modding/PieceLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,14 @@ void CraftingCategory_SettingChanged(object sender, EventArgs e) {
// Configuration Manager responsive (one visible row per piece under "Building Pieces").
PieceConfigDrawer.Attach(jbuildpiece);

// An empty/unparseable PieceCost (both the config value AND the code default failed to parse)
// used to fall through with zero requirements, registering a free-to-build piece. Refuse to
// register instead -- a piece with no valid cost is a definition error, not a free piece.
if (jbuildpiece.Cfgs.UpdatedCost.Count == 0) {
ModLogger.LogError($"{jbuildpiece.Name} has no valid build cost (empty or unparseable PieceCost); skipping registration so it cannot be built for free.");
return;
}

List<RequirementConfig> recipe = new List<RequirementConfig>();
foreach (PieceCost entry in jbuildpiece.Cfgs.UpdatedCost) {
recipe.Add(new RequirementConfig { Item = entry.Prefab, Amount = entry.Amount, Recover = entry.Refundable });
Expand Down
5 changes: 4 additions & 1 deletion Common/src/Utils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,10 @@ public static void CopyFields(object originalObject, object cloneObject, Type ty

public static bool IsServer()
{
return ZNet.instance.IsServer() || ZNet.instance.IsDedicated() || SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null;
// Callable before a world is loaded: ZNet.instance is null during plugin Awake and at the
// main menu, and the headless (dedicated) check must still work there.
return (ZNet.instance != null && (ZNet.instance.IsServer() || ZNet.instance.IsDedicated())) ||
SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null;
}
}

Expand Down
5 changes: 4 additions & 1 deletion ConsoleHelper/ConsoleHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ public static bool Prefix()
return true;
}

public static void Postfix()
// Finalizer, not postfix: InputText runs the typed command, and a throwing command
// (mistyped or from another mod) would skip a postfix -- leaving the flag latched and
// ZNet.IsServer patched-true on this client for the rest of the session.
public static void Finalizer()
{
_inputtingText = false;
}
Expand Down
Loading
Loading