From 6c97ba19a0f1e26cdde365712e411f4457394517 Mon Sep 17 00:00:00 2001 From: ZakFahey Date: Thu, 20 Aug 2026 07:31:57 -0700 Subject: [PATCH 01/12] Rough first pass at a 1.4.5.7 port All untested claudeslop but this is what I had time to do late last evening and before leaving for work today. Trying to get a first pass at something that might work. If you submit none of it and just use it for reference it won't hurt my feelings. --- TShockAPI/Bouncer.cs | 4 +- TShockAPI/Commands.cs | 2 +- TShockAPI/GetDataHandlers.cs | 67 +++++++++++++++++++++------- TShockAPI/Net/ProjectileRemoveMsg.cs | 13 +++--- TShockAPI/Rest/RestManager.cs | 18 ++++---- TShockAPI/TSPlayer.cs | 4 +- TShockAPI/TShock.cs | 4 +- TShockAPI/Utils.cs | 9 ++-- TShockLauncher/TShockLauncher.csproj | 2 +- nuget.config | 7 +++ 10 files changed, 86 insertions(+), 44 deletions(-) create mode 100644 nuget.config diff --git a/TShockAPI/Bouncer.cs b/TShockAPI/Bouncer.cs index 909a84d68f..52ec51c1b2 100644 --- a/TShockAPI/Bouncer.cs +++ b/TShockAPI/Bouncer.cs @@ -1,4 +1,4 @@ -/* +/* TShock, a server mod for Terraria Copyright (C) 2011-2019 Pryaxis & TShock Contributors @@ -2676,7 +2676,7 @@ internal void OnPlaceItemFrame(object sender, GetDataHandlers.PlaceItemFrameEven if (!args.Player.HasBuildPermission(args.X, args.Y)) { - int num = Item.NewItem(null, (args.X * 16) + 8, (args.Y * 16) + 8, args.Player.TPlayer.width, args.Player.TPlayer.height, args.ItemID, args.Stack, noBroadcast: true, args.Prefix, noGrabDelay: true); + int num = Item.NewItem(null, (args.X * 16) + 8, (args.Y * 16) + 8, args.Player.TPlayer.width, args.Player.TPlayer.height, args.ItemID, args.Stack, noBroadcast: true, args.Prefix, Terraria.NewItemOwnership.None); Main.item[num].playerIndexTheItemIsReservedFor = args.Player.Index; NetMessage.SendData((int)PacketTypes.ItemDrop, args.Player.Index, -1, NetworkText.Empty, num, 1f); NetMessage.SendData((int)PacketTypes.ItemOwner, args.Player.Index, -1, NetworkText.Empty, num); diff --git a/TShockAPI/Commands.cs b/TShockAPI/Commands.cs index 0ce138f14b..9c1d2a0b29 100644 --- a/TShockAPI/Commands.cs +++ b/TShockAPI/Commands.cs @@ -2537,7 +2537,7 @@ private static void Rain(CommandArgs args) } else { - Main.StartRain(garenteeCoinRain: true); + Main.StartRain(guaranteeCoinRain: true); TSPlayer.All.SendData(PacketTypes.WorldInfo); TSPlayer.All.SendInfoMessage(GetString("{0} caused it to coin rain.", args.Player.Name)); } diff --git a/TShockAPI/GetDataHandlers.cs b/TShockAPI/GetDataHandlers.cs index ef4b0c2e97..61d44ab6e2 100644 --- a/TShockAPI/GetDataHandlers.cs +++ b/TShockAPI/GetDataHandlers.cs @@ -3260,8 +3260,21 @@ private static bool HandleItemDrop(GetDataHandlerArgs args) var vel = new Vector2(args.Data.ReadSingle(), args.Data.ReadSingle()); var stacks = args.Data.ReadInt16(); var prefix = args.Data.ReadInt8(); - var noDelay = args.Data.ReadInt8() == 1; + BitsByte flags = args.Data.ReadInt8(); + // Bits 0-1: Terraria.NewItemOwnership (0 None, 1 ReserveForLocalPlayer, + // 2 GrabDelayForLocalPlayer, 3 GrabDelayForAllPlayers) + var ownership = (byte)((flags[0] ? 1 : 0) | (flags[1] ? 2 : 0)); + var noDelay = ownership <= 1; var type = args.Data.ReadInt16(); + if (flags[2]) + { + args.Data.ReadBoolean(); // shimmered + args.Data.ReadSingle(); // shimmerTime + } + if (flags[3]) + { + args.Data.ReadInt8(); // enemyGrabDelayTime + } if (OnItemDrop(args.Player, args.Data, id, pos, vel, stacks, prefix, noDelay, type)) return true; @@ -3271,6 +3284,8 @@ private static bool HandleItemDrop(GetDataHandlerArgs args) private static bool HandleItemOwner(GetDataHandlerArgs args) { + // As of 1.4.5.7 this packet is server->client only and vanilla ignores it inbound; + // clients no longer send it (including the old slot-400 SSC echo). var id = args.Data.ReadInt16(); var owner = args.Data.ReadInt8(); @@ -3295,10 +3310,14 @@ private static bool HandleNpcItemStrike(GetDataHandlerArgs args) private static bool HandleProjectileNew(GetDataHandlerArgs args) { - short ident = args.Data.ReadInt16(); + // 1.4.5.7: a packed ProjectileKey (spawner:8 | index:10 | generation:14) replaces + // the old identity short + owner byte; the trailing bit7 UUID short is gone. + uint key = (uint)args.Data.ReadInt32(); + byte owner = (byte)(key & 255u); + short ident = (short)(key >> 8 & 1023u); + int generation = (int)(key >> 18 & 16383u); Vector2 pos = args.Data.ReadVector2(); Vector2 vel = args.Data.ReadVector2(); - byte owner = args.Data.ReadInt8(); short type = args.Data.ReadInt16(); BitsByte bitsByte = (BitsByte)args.Data.ReadByte(); BitsByte bitsByte2 = (BitsByte)(bitsByte[2] ? args.Data.ReadByte() : 0); @@ -3310,11 +3329,17 @@ private static bool HandleProjectileNew(GetDataHandlerArgs args) short dmg = (short)(bitsByte[4] ? args.Data.ReadInt16() : 0); float knockback = bitsByte[5] ? args.Data.ReadSingle() : 0f; short origDmg = (short)(bitsByte[6] ? args.Data.ReadInt16() : 0); - short projUUID = (short)(bitsByte[7] ? args.Data.ReadInt16() : -1); - if (projUUID >= 1000) projUUID = -1; ai[2] = (bitsByte2[0] ? args.Data.ReadSingle() : 0f); - var index = TShock.Utils.SearchProjectile(ident, owner); + // Vanilla rejects keys whose spawner isn't the sending client; mirror that here. + if (owner != args.Player.Index) + { + TShock.Log.ConsoleDebug(GetString("GetDataHandlers / HandleProjectileNew rejected key spawner mismatch {0}", args.Player.Name)); + return true; + } + + // The key's index is the projectile slot; the old identity scan no longer applies. + var index = (int)ident; // Cattiva's dig ability can bypass build permissions via vanilla exploit in Terraria v1.4.5 // Block ai[0] == 3 (dig state) @@ -3344,12 +3369,19 @@ private static bool HandleProjectileNew(GetDataHandlerArgs args) private static bool HandleNpcStrike(GetDataHandlerArgs args) { - var id = args.Data.ReadInt16(); + // 1.4.5.7: slot shrank to a byte and gained a generation byte (slot-reuse guard). + // Vanilla drops strikes whose generation doesn't match and acks each one with + // packet 162 (DamageNPCAck). + short id = args.Data.ReadInt8(); + var generation = args.Data.ReadInt8(); var dmg = args.Data.ReadInt16(); var knockback = args.Data.ReadSingle(); var direction = (byte)(args.Data.ReadInt8() - 1); var crit = args.Data.ReadInt8(); + if (id >= Main.npc.Length) + return true; + if (OnNPCStrike(args.Player, args.Data, id, direction, dmg, knockback, crit)) return true; @@ -3393,10 +3425,15 @@ private static bool HandleNpcStrike(GetDataHandlerArgs args) private static bool HandleProjectileKill(GetDataHandlerArgs args) { - var ident = args.Data.ReadInt16(); - var owner = args.Data.ReadInt8(); - owner = (byte)args.Player.Index; - var index = TShock.Utils.SearchProjectile(ident, owner); + // 1.4.5.7: i32 ProjectileKey + kill-effect position replace the old identity/owner pair. + uint key = (uint)args.Data.ReadInt32(); + var killPos = args.Data.ReadVector2(); + var ident = (short)(key >> 8 & 1023u); + var owner = (byte)args.Player.Index; + var index = (int)ident; + + if (index >= Main.projectile.Length) + return true; if (OnProjectileKill(args.Player, args.Data, ident, owner, index)) { @@ -4280,8 +4317,8 @@ private static bool HandleHealOther(GetDataHandlerArgs args) private static bool HandleCatchNpc(GetDataHandlerArgs args) { + // 1.4.5.7 removed the trailing "who" byte; the server uses the sender's index. var npcID = args.Data.ReadInt16(); - var who = args.Data.ReadByte(); if (Main.npc[npcID]?.catchItem == 0) { @@ -5193,6 +5230,7 @@ public enum NetModuleType Ping, Ambience, Bestiary, + CreativeUnlocks, CreativePowers, CreativeUnlocksPlayerReport, TeleportPylon, @@ -5200,11 +5238,8 @@ public enum NetModuleType CreativePowerPermissions, Banners, CraftingRequests, - TagEffectState, LeashedEntity, - UnbreakableWallScan, - [Obsolete("Removed in 1.4.5")] - CreativeUnlocks + UnbreakableWallScan } public enum CreativePowerTypes diff --git a/TShockAPI/Net/ProjectileRemoveMsg.cs b/TShockAPI/Net/ProjectileRemoveMsg.cs index ed4d57ffa5..3255f8cd98 100644 --- a/TShockAPI/Net/ProjectileRemoveMsg.cs +++ b/TShockAPI/Net/ProjectileRemoveMsg.cs @@ -30,20 +30,21 @@ public override PacketTypes ID public short Index { get; set; } public byte Owner { get; set; } + /// Slot-reuse counter of the projectile being removed. A stale generation makes + /// clients treat the key as a fresh (type 0) projectile, which still clears the slot. + public int Generation { get; set; } public override void Pack(Stream stream) { - stream.WriteInt16(Index); + // ProjectileKey: spawner:8 | index:10 | generation:14 + int key = (Owner & 255) | (Index & 1023) << 8 | (Generation & 16383) << 18; + stream.WriteInt32(key); stream.WriteSingle(-1); stream.WriteSingle(-1); stream.WriteSingle(0); stream.WriteSingle(0); - stream.WriteSingle(0); - stream.WriteInt16(0); - stream.WriteByte(Owner); stream.WriteInt16(0); - stream.WriteSingle(0); - stream.WriteSingle(0); + stream.WriteByte(0); } } } \ No newline at end of file diff --git a/TShockAPI/Rest/RestManager.cs b/TShockAPI/Rest/RestManager.cs index 977d2533b0..6d4cdf7edb 100644 --- a/TShockAPI/Rest/RestManager.cs +++ b/TShockAPI/Rest/RestManager.cs @@ -976,9 +976,9 @@ private object PlayerReadV3(RestRequestArgs args) return ret; TSPlayer player = (TSPlayer)ret; - var inventory = player.TPlayer.inventory.Where(p => p.active).ToList(); - var equipment = player.TPlayer.armor.Where(p => p.active).ToList(); - var dyes = player.TPlayer.dye.Where(p => p.active).ToList(); + var inventory = player.TPlayer.inventory.Where(p => !p.IsAir).ToList(); + var equipment = player.TPlayer.armor.Where(p => !p.IsAir).ToList(); + var dyes = player.TPlayer.dye.Where(p => !p.IsAir).ToList(); return new RestObject() { {"nickname", player.Name}, @@ -1012,12 +1012,12 @@ private object PlayerReadV4(RestRequestArgs args) object items = new { - inventory = player.TPlayer.inventory.Where(i => i.active).Select(item => (NetItem)item), - equipment = player.TPlayer.armor.Where(i => i.active).Select(item => (NetItem)item), - dyes = player.TPlayer.dye.Where(i => i.active).Select(item => (NetItem)item), - piggy = player.TPlayer.bank.item.Where(i => i.active).Select(item => (NetItem)item), - safe = player.TPlayer.bank2.item.Where(i => i.active).Select(item => (NetItem)item), - forge = player.TPlayer.bank3.item.Where(i => i.active).Select(item => (NetItem)item) + inventory = player.TPlayer.inventory.Where(i => !i.IsAir).Select(item => (NetItem)item), + equipment = player.TPlayer.armor.Where(i => !i.IsAir).Select(item => (NetItem)item), + dyes = player.TPlayer.dye.Where(i => !i.IsAir).Select(item => (NetItem)item), + piggy = player.TPlayer.bank.item.Where(i => !i.IsAir).Select(item => (NetItem)item), + safe = player.TPlayer.bank2.item.Where(i => !i.IsAir).Select(item => (NetItem)item), + forge = player.TPlayer.bank3.item.Where(i => !i.IsAir).Select(item => (NetItem)item) }; return new RestObject diff --git a/TShockAPI/TSPlayer.cs b/TShockAPI/TSPlayer.cs index 78969f89b6..d2c462f2b2 100644 --- a/TShockAPI/TSPlayer.cs +++ b/TShockAPI/TSPlayer.cs @@ -1420,7 +1420,7 @@ public bool InventorySlotAvailable { for (int i = 0; i < 50; i++) //51 is trash can, 52-55 is coins, 56-59 is ammo { - if (TPlayer.inventory[i] == null || !TPlayer.inventory[i].active || TPlayer.inventory[i].Name == "") + if (TPlayer.inventory[i] == null || TPlayer.inventory[i].IsAir || TPlayer.inventory[i].Name == "") { flag = true; break; @@ -1984,7 +1984,7 @@ private Item GiveItemDirectly_FillEmptyInventorySlot(Item item, int slot) private void GiveItemByDrop(int type, int stack, int prefix) { - int itemIndex = Item.NewItem(new EntitySource_DebugCommand(), (int)X, (int)Y, TPlayer.width, TPlayer.height, type, stack, true, prefix, true); + int itemIndex = Item.NewItem(new EntitySource_DebugCommand(), (int)X, (int)Y, TPlayer.width, TPlayer.height, type, stack, true, prefix, Terraria.NewItemOwnership.None); Main.item[itemIndex].playerIndexTheItemIsReservedFor = this.Index; SendData(PacketTypes.ItemDrop, "", itemIndex, 1); SendData(PacketTypes.ItemOwner, null, itemIndex); diff --git a/TShockAPI/TShock.cs b/TShockAPI/TShock.cs index 450c66e52b..09e29a9074 100644 --- a/TShockAPI/TShock.cs +++ b/TShockAPI/TShock.cs @@ -1,4 +1,4 @@ -/* +/* TShock, a server mod for Terraria Copyright (C) 2011-2019 Pryaxis & TShock Contributors @@ -1066,7 +1066,7 @@ private void OnUpdate(EventArgs args) // even if there are no clients connected if (ServerApi.ForceUpdate) { - Netplay.HasClients = true; + Netplay.HasFullyConnectedClients = true; } if (Backups.IsBackupTime) diff --git a/TShockAPI/Utils.cs b/TShockAPI/Utils.cs index 8cc3be19f7..f2410c5415 100644 --- a/TShockAPI/Utils.cs +++ b/TShockAPI/Utils.cs @@ -779,11 +779,10 @@ public bool TryParseTime(string str, out ulong seconds) /// projectile ID public int SearchProjectile(short identity, int owner) { - for (int i = 0; i < Main.maxProjectiles; i++) - { - if (Main.projectile[i].identity == identity && Main.projectile[i].owner == owner) - return i; - } + // 1.4.5.7 replaced Projectile.identity with ProjectileKey, whose index component + // is the slot in Main.projectile, so the lookup is direct. + if (identity >= 0 && identity < Main.maxProjectiles && Main.projectile[identity].owner == owner) + return identity; return 1000; } diff --git a/TShockLauncher/TShockLauncher.csproj b/TShockLauncher/TShockLauncher.csproj index ae741c24f5..b5d1e997f9 100644 --- a/TShockLauncher/TShockLauncher.csproj +++ b/TShockLauncher/TShockLauncher.csproj @@ -38,7 +38,7 @@ - + PreserveNewest true diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000000..4166b9413e --- /dev/null +++ b/nuget.config @@ -0,0 +1,7 @@ + + + + + + + From ef4fc82645bdab11df2c03758b6bdf8a04a0f72f Mon Sep 17 00:00:00 2001 From: bulkinglb <72748386+bulkinglb@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:10:55 +0200 Subject: [PATCH 02/12] chore: drop local nuget source to restore otapi from nuget.org --- nuget.config | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 nuget.config diff --git a/nuget.config b/nuget.config deleted file mode 100644 index 4166b9413e..0000000000 --- a/nuget.config +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - From a1c4c332a03552f86dee438a887b9a6e4a158972 Mon Sep 17 00:00:00 2001 From: bulkinglb <72748386+bulkinglb@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:11:07 +0200 Subject: [PATCH 03/12] fix: resolve ProjectileKey via keyToIndex and check generations ProjectileKey.Index is a client-local identity, not the slot in Main.projectile; Projectile.TryLookup resolves it through Projectile.keyToIndex[spawner, index]. Every handler using the raw index was acting on an unrelated projectile. Utils.SearchProjectile now goes through keyToIndex and re-validates the key, replacing the old linear identity scan. HandleProjectileNew mirrors vanilla's Main.projHostile rejection. HandleProjectileKill resolves via ProjectileKey.TryGet so generations are compared, dropping stale keys and owner mismatches. HandleNpcStrike drops strikes whose generation doesn't match the NPC in the slot. TSPlayer.RemoveProjectile now sends the live generation. With the old hardcoded 0 the client allocated a fresh slot instead of clearing the projectile, silently breaking every Bouncer removal. --- TShockAPI/GetDataHandlers.cs | 54 +++++++++++++++++++++------- TShockAPI/Net/ProjectileRemoveMsg.cs | 5 +-- TShockAPI/TSPlayer.cs | 12 ++++++- TShockAPI/Utils.cs | 21 ++++++++--- 4 files changed, 71 insertions(+), 21 deletions(-) diff --git a/TShockAPI/GetDataHandlers.cs b/TShockAPI/GetDataHandlers.cs index 61d44ab6e2..ee5bb9d72e 100644 --- a/TShockAPI/GetDataHandlers.cs +++ b/TShockAPI/GetDataHandlers.cs @@ -3310,12 +3310,11 @@ private static bool HandleNpcItemStrike(GetDataHandlerArgs args) private static bool HandleProjectileNew(GetDataHandlerArgs args) { - // 1.4.5.7: a packed ProjectileKey (spawner:8 | index:10 | generation:14) replaces - // the old identity short + owner byte; the trailing bit7 UUID short is gone. - uint key = (uint)args.Data.ReadInt32(); - byte owner = (byte)(key & 255u); - short ident = (short)(key >> 8 & 1023u); - int generation = (int)(key >> 18 & 16383u); + // 1.4.5.7: a packed ProjectileKey replaces the old identity short + owner byte; + // the trailing bit7 UUID short is gone. + var key = (ProjectileKey)args.Data.ReadInt32(); + byte owner = (byte)key.Spawner; + short ident = (short)key.Index; Vector2 pos = args.Data.ReadVector2(); Vector2 vel = args.Data.ReadVector2(); short type = args.Data.ReadInt16(); @@ -3331,15 +3330,23 @@ private static bool HandleProjectileNew(GetDataHandlerArgs args) short origDmg = (short)(bitsByte[6] ? args.Data.ReadInt16() : 0); ai[2] = (bitsByte2[0] ? args.Data.ReadSingle() : 0f); - // Vanilla rejects keys whose spawner isn't the sending client; mirror that here. + // Vanilla drops hostile projectile types and keys whose spawner isn't the sending + // client before touching any state; mirror both here. + if (type < 0 || type >= Main.projHostile.Length || Main.projHostile[type]) + { + TShock.Log.ConsoleDebug(GetString("GetDataHandlers / HandleProjectileNew rejected hostile projectile type {0}", args.Player.Name)); + return true; + } + if (owner != args.Player.Index) { TShock.Log.ConsoleDebug(GetString("GetDataHandlers / HandleProjectileNew rejected key spawner mismatch {0}", args.Player.Name)); return true; } - // The key's index is the projectile slot; the old identity scan no longer applies. - var index = (int)ident; + // Resolves to 1000 for a projectile the server has not created yet, matching what + // the pre-1.4.5.7 identity scan returned in the same situation. + var index = TShock.Utils.SearchProjectile(ident, owner); // Cattiva's dig ability can bypass build permissions via vanilla exploit in Terraria v1.4.5 // Block ai[0] == 3 (dig state) @@ -3382,6 +3389,14 @@ private static bool HandleNpcStrike(GetDataHandlerArgs args) if (id >= Main.npc.Length) return true; + // Vanilla drops the strike outright when the generation counter doesn't match the + // NPC currently in that slot, which is what stops a recycled slot from being hit. + if (Main.npc[id].generation != generation) + { + TShock.Log.ConsoleDebug(GetString("GetDataHandlers / HandleNpcStrike rejected npc generation mismatch {0}", args.Player.Name)); + return true; + } + if (OnNPCStrike(args.Player, args.Data, id, direction, dmg, knockback, crit)) return true; @@ -3426,14 +3441,27 @@ private static bool HandleNpcStrike(GetDataHandlerArgs args) private static bool HandleProjectileKill(GetDataHandlerArgs args) { // 1.4.5.7: i32 ProjectileKey + kill-effect position replace the old identity/owner pair. - uint key = (uint)args.Data.ReadInt32(); + var key = (ProjectileKey)args.Data.ReadInt32(); var killPos = args.Data.ReadVector2(); - var ident = (short)(key >> 8 & 1023u); + var ident = (short)key.Index; var owner = (byte)args.Player.Index; - var index = (int)ident; - if (index >= Main.projectile.Length) + // Vanilla resolves the key through Projectile.TryLookup, which compares the whole + // key including its generation counter, so a stale key kills nothing. Drop those + // rather than acting on whatever currently occupies the slot. + if (!key.TryGet(out var killed) || !killed.active) + { + TShock.Log.ConsoleDebug(GetString("GetDataHandlers / HandleProjectileKill rejected stale projectile key {0}", args.Player.Name)); return true; + } + + var index = killed.whoAmI; + + if (killed.owner != args.Player.Index) + { + TShock.Log.ConsoleDebug(GetString("GetDataHandlers / HandleProjectileKill rejected owner mismatch {0}", args.Player.Name)); + return true; + } if (OnProjectileKill(args.Player, args.Data, ident, owner, index)) { diff --git a/TShockAPI/Net/ProjectileRemoveMsg.cs b/TShockAPI/Net/ProjectileRemoveMsg.cs index 3255f8cd98..600b032812 100644 --- a/TShockAPI/Net/ProjectileRemoveMsg.cs +++ b/TShockAPI/Net/ProjectileRemoveMsg.cs @@ -30,8 +30,9 @@ public override PacketTypes ID public short Index { get; set; } public byte Owner { get; set; } - /// Slot-reuse counter of the projectile being removed. A stale generation makes - /// clients treat the key as a fresh (type 0) projectile, which still clears the slot. + /// Slot-reuse counter of the projectile being removed. This MUST match the live + /// projectile's generation: the client resolves the key through Projectile.TryLookup, and + /// on a mismatch it allocates a brand new slot instead of clearing the offending one. public int Generation { get; set; } public override void Pack(Stream stream) diff --git a/TShockAPI/TSPlayer.cs b/TShockAPI/TSPlayer.cs index d2c462f2b2..7fc792aaa5 100644 --- a/TShockAPI/TSPlayer.cs +++ b/TShockAPI/TSPlayer.cs @@ -1740,12 +1740,22 @@ public void Spawn(int tilex, int tiley, PlayerSpawnContext context, int? respawn /// The projectile's owner. public void RemoveProjectile(int index, int owner) { + // `index` is the projectile's identity, not its slot. Since 1.4.5.7 the client keys + // projectiles on (spawner, identity, generation), so resolve the live projectile and + // reuse its generation - a stale one makes the client spawn a new blank projectile + // and leave the one we're trying to remove untouched. + int generation = 0; + int slot = TShock.Utils.SearchProjectile((short)index, owner); + if (slot >= 0 && slot < Main.maxProjectiles) + generation = Main.projectile[slot].key.Generation; + using (var ms = new MemoryStream()) { var msg = new ProjectileRemoveMsg { Index = (short)index, - Owner = (byte)owner + Owner = (byte)owner, + Generation = generation }; msg.PackFull(ms); SendRawData(ms.ToArray()); diff --git a/TShockAPI/Utils.cs b/TShockAPI/Utils.cs index f2410c5415..5050adf6b5 100644 --- a/TShockAPI/Utils.cs +++ b/TShockAPI/Utils.cs @@ -1,4 +1,4 @@ -/* +/* TShock, a server mod for Terraria Copyright (C) 2011-2019 Pryaxis & TShock Contributors @@ -779,10 +779,21 @@ public bool TryParseTime(string str, out ulong seconds) /// projectile ID public int SearchProjectile(short identity, int owner) { - // 1.4.5.7 replaced Projectile.identity with ProjectileKey, whose index component - // is the slot in Main.projectile, so the lookup is direct. - if (identity >= 0 && identity < Main.maxProjectiles && Main.projectile[identity].owner == owner) - return identity; + // 1.4.5.7 replaced the Projectile.identity field with ProjectileKey. The key's Index + // component is a client-local identity, NOT the server slot; Projectile.keyToIndex + // maps (spawner, identity) onto the real index in Main.projectile. + if (identity < 0 || identity > 1000 || owner < 0 || owner > 255) + return 1000; + + int index = Projectile.keyToIndex[owner, identity]; + if (index < 0 || index >= Main.maxProjectiles) + return 1000; + + // keyToIndex entries are never cleared, so confirm the slot still holds this key. + var key = Main.projectile[index].key; + if (key.Spawner == owner && key.Index == identity) + return index; + return 1000; } From 4518a52bcccb9f99b06833932997b68f4a034742 Mon Sep 17 00:00:00 2001 From: bulkinglb <72748386+bulkinglb@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:39:02 +0200 Subject: [PATCH 04/12] fix: send the client's generation when removing a projectile Bouncer rejects a projectile before vanilla creates it, so the server-side generation lookup in RemoveProjectile can never resolve one - it fell back to 0, and the client then allocated a fresh blank slot while the rejected projectile kept flying. Every banned-projectile removal was affected. Carry the generation from the incoming ProjectileKey through NewProjectileEventArgs and ProjectileKillEventArgs, and add a RemoveProjectile overload that takes it. The two-argument overload keeps the server-side lookup for plugins already calling it. --- TShockAPI/Bouncer.cs | 33 ++++++++++++++++++--------------- TShockAPI/GetDataHandlers.cs | 20 +++++++++++++++----- TShockAPI/TSPlayer.cs | 13 +++++++++++++ 3 files changed, 46 insertions(+), 20 deletions(-) diff --git a/TShockAPI/Bouncer.cs b/TShockAPI/Bouncer.cs index 52ec51c1b2..844c075e4c 100644 --- a/TShockAPI/Bouncer.cs +++ b/TShockAPI/Bouncer.cs @@ -1312,6 +1312,9 @@ internal void OnNewProjectile(object sender, GetDataHandlers.NewProjectileEventA short type = args.Type; int index = args.Index; float[] ai = args.Ai; + // The projectile is rejected before vanilla creates it, so the server can't look its + // generation up afterwards - it has to come from the packet. + int generation = args.Generation; // Clients do send NaN values so we can't just kick them // See https://github.com/Pryaxis/TShock/issues/3076 @@ -1332,7 +1335,7 @@ internal void OnNewProjectile(object sender, GetDataHandlers.NewProjectileEventA if (index > Main.maxProjectiles) { TShock.Log.ConsoleDebug(GetString("Bouncer / OnNewProjectile rejected from above projectile limit from {0}", args.Player.Name)); - args.Player.RemoveProjectile(ident, owner); + args.Player.RemoveProjectile(ident, owner, generation); args.Handled = true; return; } @@ -1343,7 +1346,7 @@ internal void OnNewProjectile(object sender, GetDataHandlers.NewProjectileEventA // Client will fight the server if we remove pets, silently reject instead if (!Main.projPet[type] && !ProjectileID.Sets.LightPet[type]) - args.Player.RemoveProjectile(ident, owner); + args.Player.RemoveProjectile(ident, owner, generation); args.Handled = true; return; @@ -1355,7 +1358,7 @@ internal void OnNewProjectile(object sender, GetDataHandlers.NewProjectileEventA // Client will fight the server if we remove pets, silently reject instead if (!Main.projPet[type] && !ProjectileID.Sets.LightPet[type]) - args.Player.RemoveProjectile(ident, owner); + args.Player.RemoveProjectile(ident, owner, generation); args.Handled = true; return; @@ -1366,7 +1369,7 @@ internal void OnNewProjectile(object sender, GetDataHandlers.NewProjectileEventA args.Player.Disable(GetString("Player does not have permission to create projectile {0}.", type), DisableFlags.WriteToLogAndConsole); TShock.Log.ConsoleDebug(GetString("Bouncer / OnNewProjectile rejected from permission check from {0} {1}", args.Player.Name, type)); args.Player.SendErrorMessage(GetString("You do not have permission to create that projectile.")); - args.Player.RemoveProjectile(ident, owner); + args.Player.RemoveProjectile(ident, owner, generation); args.Handled = true; return; } @@ -1375,7 +1378,7 @@ internal void OnNewProjectile(object sender, GetDataHandlers.NewProjectileEventA { args.Player.Disable(GetString("Projectile damage is higher than {0}.", TShock.Config.Settings.MaxProjDamage), DisableFlags.WriteToLogAndConsole); TShock.Log.ConsoleDebug(GetString("Bouncer / OnNewProjectile rejected from projectile damage limit from {0} {1}/{2}", args.Player.Name, damage, TShock.Config.Settings.MaxProjDamage)); - args.Player.RemoveProjectile(ident, owner); + args.Player.RemoveProjectile(ident, owner, generation); args.Handled = true; return; } @@ -1412,7 +1415,7 @@ internal void OnNewProjectile(object sender, GetDataHandlers.NewProjectileEventA if (Main.projHostile[type]) { TShock.Log.ConsoleDebug(GetString("Bouncer / OnNewProjectile rejected from hostile projectile from {0}", args.Player.Name)); - args.Player.RemoveProjectile(ident, owner); + args.Player.RemoveProjectile(ident, owner, generation); args.Handled = true; return; } @@ -1423,7 +1426,7 @@ internal void OnNewProjectile(object sender, GetDataHandlers.NewProjectileEventA if (type == ProjectileID.Tombstone) { TShock.Log.ConsoleDebug(GetString("Bouncer / OnNewProjectile rejected from tombstones from {0}", args.Player.Name)); - args.Player.RemoveProjectile(ident, owner); + args.Player.RemoveProjectile(ident, owner, generation); args.Handled = true; return; } @@ -1439,7 +1442,7 @@ internal void OnNewProjectile(object sender, GetDataHandlers.NewProjectileEventA if (discreteDirection is < -3 or > 4) { TShock.Log.ConsoleDebug(GetString("Bouncer / OnNewProjectile rejected from portal gate from {0} (invalid angle: {1})", args.Player.Name, discreteDirection)); - args.Player.RemoveProjectile(ident, owner); + args.Player.RemoveProjectile(ident, owner, generation); args.Handled = true; return; } @@ -1449,7 +1452,7 @@ internal void OnNewProjectile(object sender, GetDataHandlers.NewProjectileEventA if (boltProjectileData.Type == 0 || boltProjectileData.Killed) { TShock.Log.ConsoleDebug(GetString("Bouncer / OnNewProjectile rejected from portal gate from {0} (missing active Portal Gun bolt)", args.Player.Name, discreteDirection)); - args.Player.RemoveProjectile(ident, owner); + args.Player.RemoveProjectile(ident, owner, generation); args.Handled = true; return; } @@ -1478,7 +1481,7 @@ internal void OnNewProjectile(object sender, GetDataHandlers.NewProjectileEventA { TShock.Log.ConsoleDebug(GetString("Bouncer / OnNewProjectile please report to tshock about this! normally this is a reject from {0} {1}", args.Player.Name, type)); // args.Player.Disable(String.Format("Does not have projectile permission to update projectile. ({0})", type), DisableFlags.WriteToLogAndConsole); - // args.Player.RemoveProjectile(ident, owner); + // args.Player.RemoveProjectile(ident, owner, generation); } // args.Handled = false; // return; @@ -1493,7 +1496,7 @@ internal void OnNewProjectile(object sender, GetDataHandlers.NewProjectileEventA else { args.Player.Disable(GetString("Reached projectile create threshold."), DisableFlags.WriteToLogAndConsole); - args.Player.RemoveProjectile(ident, owner); + args.Player.RemoveProjectile(ident, owner, generation); } TShock.Log.ConsoleDebug(GetString("Bouncer / OnNewProjectile rejected from projectile create threshold from {0} {1}/{2}", args.Player.Name, args.Player.ProjectileThreshold, TShock.Config.Settings.ProjectileThreshold)); @@ -1510,7 +1513,7 @@ internal void OnNewProjectile(object sender, GetDataHandlers.NewProjectileEventA ) { TShock.Log.ConsoleDebug(GetString("Bouncer / OnNewProjectile rejected from bouncer modified AI from {0}.", args.Player.Name)); - args.Player.RemoveProjectile(ident, owner); + args.Player.RemoveProjectile(ident, owner, generation); args.Handled = true; return; } @@ -1526,7 +1529,7 @@ internal void OnNewProjectile(object sender, GetDataHandlers.NewProjectileEventA if (TShock.Config.Settings.DisableModifiedZenith && type == ProjectileID.FinalFractal && (ai[0] < -100 || ai[0] > 101) && !Terraria.Graphics.FinalFractalHelper._fractalProfiles.ContainsKey((int)ai[1])) { TShock.Log.ConsoleDebug(GetString("Bouncer / OnNewProjectile rejected from bouncer modified Zenith projectile from {0}.", args.Player.Name)); - args.Player.RemoveProjectile(ident, owner); + args.Player.RemoveProjectile(ident, owner, generation); args.Handled = true; return; } @@ -1632,7 +1635,7 @@ internal void OnProjectileKill(object sender, GetDataHandlers.ProjectileKillEven if (args.Player.IsBeingDisabled()) { TShock.Log.ConsoleDebug(GetString("Bouncer / OnProjectileKill rejected from disabled from {0}", args.Player.Name)); - args.Player.RemoveProjectile(args.ProjectileIdentity, args.ProjectileOwner); + args.Player.RemoveProjectile(args.ProjectileIdentity, args.ProjectileOwner, args.ProjectileGeneration); args.Handled = true; return; } @@ -1640,7 +1643,7 @@ internal void OnProjectileKill(object sender, GetDataHandlers.ProjectileKillEven if (args.Player.IsBouncerThrottled()) { TShock.Log.ConsoleDebug(GetString("Bouncer / OnProjectileKill rejected from bouncer throttle from {0}", args.Player.Name)); - args.Player.RemoveProjectile(args.ProjectileIdentity, args.ProjectileOwner); + args.Player.RemoveProjectile(args.ProjectileIdentity, args.ProjectileOwner, args.ProjectileGeneration); args.Handled = true; return; } diff --git a/TShockAPI/GetDataHandlers.cs b/TShockAPI/GetDataHandlers.cs index ee5bb9d72e..b56c6e0045 100644 --- a/TShockAPI/GetDataHandlers.cs +++ b/TShockAPI/GetDataHandlers.cs @@ -700,6 +700,12 @@ public class NewProjectileEventArgs : GetDataHandledEventArgs /// public int Index { get; set; } + /// + /// Slot-reuse counter from the sender's ProjectileKey. The server has not created + /// the projectile yet at this point, so this is the only way to address it back. + /// + public int Generation { get; set; } + /// /// The special meaning of the projectile. /// @@ -709,7 +715,7 @@ public class NewProjectileEventArgs : GetDataHandledEventArgs /// NewProjectile - Called when a client creates a new projectile /// public static HandlerList NewProjectile = new HandlerList(); - private static bool OnNewProjectile(MemoryStream data, short ident, Vector2 pos, Vector2 vel, float knockback, short dmg, byte owner, short type, int index, TSPlayer player, float[] ai) + private static bool OnNewProjectile(MemoryStream data, short ident, Vector2 pos, Vector2 vel, float knockback, short dmg, byte owner, short type, int index, TSPlayer player, float[] ai, int generation) { if (NewProjectile == null) return false; @@ -726,7 +732,8 @@ private static bool OnNewProjectile(MemoryStream data, short ident, Vector2 pos, Type = type, Index = index, Player = player, - Ai = ai + Ai = ai, + Generation = generation }; NewProjectile.Invoke(null, args); return args.Handled; @@ -790,6 +797,8 @@ public class ProjectileKillEventArgs : GetDataHandledEventArgs public byte ProjectileOwner; /// The index of the projectile in Main.projectile. public int ProjectileIndex; + /// Slot-reuse counter from the sender's ProjectileKey. + public int ProjectileGeneration; } /// The event fired when a projectile kill packet is received. public static HandlerList ProjectileKill = new HandlerList(); @@ -800,7 +809,7 @@ public class ProjectileKillEventArgs : GetDataHandledEventArgs /// The projectile's owner (from the packet). /// The projectile's index (from Main.projectiles). /// bool - private static bool OnProjectileKill(TSPlayer player, MemoryStream data, int identity, byte owner, int index) + private static bool OnProjectileKill(TSPlayer player, MemoryStream data, int identity, byte owner, int index, int generation) { if (ProjectileKill == null) return false; @@ -812,6 +821,7 @@ private static bool OnProjectileKill(TSPlayer player, MemoryStream data, int ide ProjectileIdentity = identity, ProjectileOwner = owner, ProjectileIndex = index, + ProjectileGeneration = generation, }; ProjectileKill.Invoke(null, args); @@ -3356,7 +3366,7 @@ private static bool HandleProjectileNew(GetDataHandlerArgs args) return true; } - if (OnNewProjectile(args.Data, ident, pos, vel, knockback, dmg, owner, type, index, args.Player, ai)) + if (OnNewProjectile(args.Data, ident, pos, vel, knockback, dmg, owner, type, index, args.Player, ai, key.Generation)) return true; lock (args.Player.RecentlyCreatedProjectiles) @@ -3463,7 +3473,7 @@ private static bool HandleProjectileKill(GetDataHandlerArgs args) return true; } - if (OnProjectileKill(args.Player, args.Data, ident, owner, index)) + if (OnProjectileKill(args.Player, args.Data, ident, owner, index, key.Generation)) { return true; } diff --git a/TShockAPI/TSPlayer.cs b/TShockAPI/TSPlayer.cs index 7fc792aaa5..3429cb66b5 100644 --- a/TShockAPI/TSPlayer.cs +++ b/TShockAPI/TSPlayer.cs @@ -1749,6 +1749,19 @@ public void RemoveProjectile(int index, int owner) if (slot >= 0 && slot < Main.maxProjectiles) generation = Main.projectile[slot].key.Generation; + RemoveProjectile(index, owner, generation); + } + + /// + /// Removes a projectile whose generation is already known - typically straight from the + /// packet that asked for it. Prefer this when rejecting a projectile the server has not + /// created yet, because the server-side lookup cannot resolve one that doesn't exist. + /// + /// The projectile's identity. + /// The player index of the projectile's owner. + /// Slot-reuse counter from the sender's ProjectileKey. + public void RemoveProjectile(int index, int owner, int generation) + { using (var ms = new MemoryStream()) { var msg = new ProjectileRemoveMsg From c8f0afc40408b1b32606c20d57e0ea8585aded82 Mon Sep 17 00:00:00 2001 From: bulkinglb <72748386+bulkinglb@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:39:03 +0200 Subject: [PATCH 05/12] fix: bounds-check the npc id in the catch npc handler Vanilla range-checks the id before touching Main.npc; TShock read it straight from the packet and indexed with it, so a crafted id threw. Also corrects the ItemOwner comment: a 1.4.5.7 server ignores that packet entirely, and SSC no longer depends on the slot-400 branch because RestoreCharacter clears IgnoreSSCPackets in a finally block. --- TShockAPI/GetDataHandlers.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/TShockAPI/GetDataHandlers.cs b/TShockAPI/GetDataHandlers.cs index b56c6e0045..3cb1a339c4 100644 --- a/TShockAPI/GetDataHandlers.cs +++ b/TShockAPI/GetDataHandlers.cs @@ -3294,8 +3294,11 @@ private static bool HandleItemDrop(GetDataHandlerArgs args) private static bool HandleItemOwner(GetDataHandlerArgs args) { - // As of 1.4.5.7 this packet is server->client only and vanilla ignores it inbound; - // clients no longer send it (including the old slot-400 SSC echo). + // As of 1.4.5.7 vanilla's case 22 is guarded by `Main.netMode != 2`, so a server + // ignores this packet entirely and its payload changed shape (7-bit encoded ints, + // trailing position). The slot-400 branch below is therefore dead - SSC no longer + // depends on it, since RestoreCharacter clears IgnoreSSCPackets in a finally block. + // Kept only so an old client echoing the packet still behaves. var id = args.Data.ReadInt16(); var owner = args.Data.ReadInt8(); @@ -4358,6 +4361,14 @@ private static bool HandleCatchNpc(GetDataHandlerArgs args) // 1.4.5.7 removed the trailing "who" byte; the server uses the sender's index. var npcID = args.Data.ReadInt16(); + // Vanilla range-checks this before touching Main.npc. Without it a crafted id + // indexes out of bounds. + if (npcID < 0 || npcID >= Main.maxNPCs) + { + TShock.Log.ConsoleDebug(GetString("GetDataHandlers / HandleCatchNpc rejected out of range npc {0}", args.Player.Name)); + return true; + } + if (Main.npc[npcID]?.catchItem == 0) { TShock.Log.ConsoleDebug(GetString("GetDataHandlers / HandleCatchNpc catch zero {0}", args.Player.Name)); From 0dd133e67e7343d5451f82ad02ba1b8788ecef52 Mon Sep 17 00:00:00 2001 From: bulkinglb <72748386+bulkinglb@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:49:40 +0200 Subject: [PATCH 06/12] fix: bounds check the projectile index in the projectile kill handler --- TShockAPI/GetDataHandlers.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/TShockAPI/GetDataHandlers.cs b/TShockAPI/GetDataHandlers.cs index 3cb1a339c4..6b7f1f1b36 100644 --- a/TShockAPI/GetDataHandlers.cs +++ b/TShockAPI/GetDataHandlers.cs @@ -3459,6 +3459,15 @@ private static bool HandleProjectileKill(GetDataHandlerArgs args) var ident = (short)key.Index; var owner = (byte)args.Player.Index; + // ProjectileKey.Index is a 10 bit field (0-1023) but Projectile.keyToIndex only has + // Main.maxProjectiles + 1 columns, and TryGet does not bounds check the lookup, so an + // out of range index throws rather than simply missing. + if (key.Index > Main.maxProjectiles) + { + TShock.Log.ConsoleDebug(GetString("GetDataHandlers / HandleProjectileKill rejected out of range projectile index {0}", args.Player.Name)); + return true; + } + // Vanilla resolves the key through Projectile.TryLookup, which compares the whole // key including its generation counter, so a stale key kills nothing. Drop those // rather than acting on whatever currently occupies the slot. From 3b02fd2d011695356cc7f02e119d09b1a9b6962f Mon Sep 17 00:00:00 2001 From: bulkinglb <72748386+bulkinglb@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:52:26 +0200 Subject: [PATCH 07/12] fix: check the projectile generation when resolving a new projectile --- TShockAPI/GetDataHandlers.cs | 5 +++-- TShockAPI/Utils.cs | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/TShockAPI/GetDataHandlers.cs b/TShockAPI/GetDataHandlers.cs index 6b7f1f1b36..2a5b69dbd1 100644 --- a/TShockAPI/GetDataHandlers.cs +++ b/TShockAPI/GetDataHandlers.cs @@ -3358,8 +3358,9 @@ private static bool HandleProjectileNew(GetDataHandlerArgs args) } // Resolves to 1000 for a projectile the server has not created yet, matching what - // the pre-1.4.5.7 identity scan returned in the same situation. - var index = TShock.Utils.SearchProjectile(ident, owner); + // the pre-1.4.5.7 identity scan returned in the same situation. The generation keeps a + // reused identity from resolving onto the projectile that previously held it. + var index = TShock.Utils.SearchProjectile(ident, owner, key.Generation); // Cattiva's dig ability can bypass build permissions via vanilla exploit in Terraria v1.4.5 // Block ai[0] == 3 (dig state) diff --git a/TShockAPI/Utils.cs b/TShockAPI/Utils.cs index 5050adf6b5..c18509d126 100644 --- a/TShockAPI/Utils.cs +++ b/TShockAPI/Utils.cs @@ -797,6 +797,28 @@ public int SearchProjectile(short identity, int owner) return 1000; } + /// + /// Searches for a projectile by identity, owner and generation. + /// + /// identity + /// owner + /// slot-reuse counter from the sender's ProjectileKey + /// projectile ID + public int SearchProjectile(short identity, int owner, int generation) + { + // An identity is reused as soon as the client retires it, so matching only spawner and + // identity resolves a new projectile onto the slot its predecessor still occupies. + // Vanilla compares the whole key; the generation is what separates the two. + int index = SearchProjectile(identity, owner); + if (index < 0 || index >= Main.maxProjectiles) + return 1000; + + if (Main.projectile[index].key.Generation != generation) + return 1000; + + return index; + } + /// /// Enumerates boundary points of the given region's rectangle. /// From e1b0d2c2e39339c0a5983fc2f7b8c50e98905402 Mon Sep 17 00:00:00 2001 From: bulkinglb <72748386+bulkinglb@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:05:30 +0200 Subject: [PATCH 08/12] style: trim superfluous comments --- TShockAPI/Bouncer.cs | 2 -- TShockAPI/GetDataHandlers.cs | 41 +++++++--------------------- TShockAPI/Net/ProjectileRemoveMsg.cs | 4 +-- TShockAPI/TSPlayer.cs | 9 ++---- TShockAPI/Utils.cs | 10 ++----- 5 files changed, 16 insertions(+), 50 deletions(-) diff --git a/TShockAPI/Bouncer.cs b/TShockAPI/Bouncer.cs index 844c075e4c..2dbde82a5d 100644 --- a/TShockAPI/Bouncer.cs +++ b/TShockAPI/Bouncer.cs @@ -1312,8 +1312,6 @@ internal void OnNewProjectile(object sender, GetDataHandlers.NewProjectileEventA short type = args.Type; int index = args.Index; float[] ai = args.Ai; - // The projectile is rejected before vanilla creates it, so the server can't look its - // generation up afterwards - it has to come from the packet. int generation = args.Generation; // Clients do send NaN values so we can't just kick them diff --git a/TShockAPI/GetDataHandlers.cs b/TShockAPI/GetDataHandlers.cs index 2a5b69dbd1..21e841c13a 100644 --- a/TShockAPI/GetDataHandlers.cs +++ b/TShockAPI/GetDataHandlers.cs @@ -701,8 +701,7 @@ public class NewProjectileEventArgs : GetDataHandledEventArgs public int Index { get; set; } /// - /// Slot-reuse counter from the sender's ProjectileKey. The server has not created - /// the projectile yet at this point, so this is the only way to address it back. + /// Slot-reuse counter from the sender's ProjectileKey. /// public int Generation { get; set; } @@ -3271,8 +3270,7 @@ private static bool HandleItemDrop(GetDataHandlerArgs args) var stacks = args.Data.ReadInt16(); var prefix = args.Data.ReadInt8(); BitsByte flags = args.Data.ReadInt8(); - // Bits 0-1: Terraria.NewItemOwnership (0 None, 1 ReserveForLocalPlayer, - // 2 GrabDelayForLocalPlayer, 3 GrabDelayForAllPlayers) + // bits 0-1: NewItemOwnership (0 none, 1 reserveLocal, 2 grabDelayLocal, 3 grabDelayAll) var ownership = (byte)((flags[0] ? 1 : 0) | (flags[1] ? 2 : 0)); var noDelay = ownership <= 1; var type = args.Data.ReadInt16(); @@ -3294,11 +3292,7 @@ private static bool HandleItemDrop(GetDataHandlerArgs args) private static bool HandleItemOwner(GetDataHandlerArgs args) { - // As of 1.4.5.7 vanilla's case 22 is guarded by `Main.netMode != 2`, so a server - // ignores this packet entirely and its payload changed shape (7-bit encoded ints, - // trailing position). The slot-400 branch below is therefore dead - SSC no longer - // depends on it, since RestoreCharacter clears IgnoreSSCPackets in a finally block. - // Kept only so an old client echoing the packet still behaves. + // dead since 1.4.5.7 (vanilla guards case 22 with netMode != 2); kept for old clients var id = args.Data.ReadInt16(); var owner = args.Data.ReadInt8(); @@ -3323,8 +3317,7 @@ private static bool HandleNpcItemStrike(GetDataHandlerArgs args) private static bool HandleProjectileNew(GetDataHandlerArgs args) { - // 1.4.5.7: a packed ProjectileKey replaces the old identity short + owner byte; - // the trailing bit7 UUID short is gone. + // 1.4.5.7: packed ProjectileKey replaces identity+owner, no trailing UUID short var key = (ProjectileKey)args.Data.ReadInt32(); byte owner = (byte)key.Spawner; short ident = (short)key.Index; @@ -3343,8 +3336,6 @@ private static bool HandleProjectileNew(GetDataHandlerArgs args) short origDmg = (short)(bitsByte[6] ? args.Data.ReadInt16() : 0); ai[2] = (bitsByte2[0] ? args.Data.ReadSingle() : 0f); - // Vanilla drops hostile projectile types and keys whose spawner isn't the sending - // client before touching any state; mirror both here. if (type < 0 || type >= Main.projHostile.Length || Main.projHostile[type]) { TShock.Log.ConsoleDebug(GetString("GetDataHandlers / HandleProjectileNew rejected hostile projectile type {0}", args.Player.Name)); @@ -3357,9 +3348,6 @@ private static bool HandleProjectileNew(GetDataHandlerArgs args) return true; } - // Resolves to 1000 for a projectile the server has not created yet, matching what - // the pre-1.4.5.7 identity scan returned in the same situation. The generation keeps a - // reused identity from resolving onto the projectile that previously held it. var index = TShock.Utils.SearchProjectile(ident, owner, key.Generation); // Cattiva's dig ability can bypass build permissions via vanilla exploit in Terraria v1.4.5 @@ -3390,9 +3378,7 @@ private static bool HandleProjectileNew(GetDataHandlerArgs args) private static bool HandleNpcStrike(GetDataHandlerArgs args) { - // 1.4.5.7: slot shrank to a byte and gained a generation byte (slot-reuse guard). - // Vanilla drops strikes whose generation doesn't match and acks each one with - // packet 162 (DamageNPCAck). + // 1.4.5.7: byte slot + generation byte; ack is packet 162 (DamageNPCAck) short id = args.Data.ReadInt8(); var generation = args.Data.ReadInt8(); var dmg = args.Data.ReadInt16(); @@ -3403,8 +3389,6 @@ private static bool HandleNpcStrike(GetDataHandlerArgs args) if (id >= Main.npc.Length) return true; - // Vanilla drops the strike outright when the generation counter doesn't match the - // NPC currently in that slot, which is what stops a recycled slot from being hit. if (Main.npc[id].generation != generation) { TShock.Log.ConsoleDebug(GetString("GetDataHandlers / HandleNpcStrike rejected npc generation mismatch {0}", args.Player.Name)); @@ -3454,24 +3438,20 @@ private static bool HandleNpcStrike(GetDataHandlerArgs args) private static bool HandleProjectileKill(GetDataHandlerArgs args) { - // 1.4.5.7: i32 ProjectileKey + kill-effect position replace the old identity/owner pair. + // 1.4.5.7: i32 ProjectileKey + kill position replace identity/owner var key = (ProjectileKey)args.Data.ReadInt32(); var killPos = args.Data.ReadVector2(); var ident = (short)key.Index; var owner = (byte)args.Player.Index; - // ProjectileKey.Index is a 10 bit field (0-1023) but Projectile.keyToIndex only has - // Main.maxProjectiles + 1 columns, and TryGet does not bounds check the lookup, so an - // out of range index throws rather than simply missing. + // Index is 10 bits (0-1023), keyToIndex only maxProjectiles+1 wide, TryGet does not bounds check if (key.Index > Main.maxProjectiles) { TShock.Log.ConsoleDebug(GetString("GetDataHandlers / HandleProjectileKill rejected out of range projectile index {0}", args.Player.Name)); return true; } - // Vanilla resolves the key through Projectile.TryLookup, which compares the whole - // key including its generation counter, so a stale key kills nothing. Drop those - // rather than acting on whatever currently occupies the slot. + // vanilla compares the generation too, so a stale key kills nothing if (!key.TryGet(out var killed) || !killed.active) { TShock.Log.ConsoleDebug(GetString("GetDataHandlers / HandleProjectileKill rejected stale projectile key {0}", args.Player.Name)); @@ -4368,11 +4348,10 @@ private static bool HandleHealOther(GetDataHandlerArgs args) private static bool HandleCatchNpc(GetDataHandlerArgs args) { - // 1.4.5.7 removed the trailing "who" byte; the server uses the sender's index. + // 1.4.5.7: no trailing "who" byte, server uses the sender's index var npcID = args.Data.ReadInt16(); - // Vanilla range-checks this before touching Main.npc. Without it a crafted id - // indexes out of bounds. + // a crafted id would index out of bounds if (npcID < 0 || npcID >= Main.maxNPCs) { TShock.Log.ConsoleDebug(GetString("GetDataHandlers / HandleCatchNpc rejected out of range npc {0}", args.Player.Name)); diff --git a/TShockAPI/Net/ProjectileRemoveMsg.cs b/TShockAPI/Net/ProjectileRemoveMsg.cs index 600b032812..279e32f5b6 100644 --- a/TShockAPI/Net/ProjectileRemoveMsg.cs +++ b/TShockAPI/Net/ProjectileRemoveMsg.cs @@ -30,9 +30,7 @@ public override PacketTypes ID public short Index { get; set; } public byte Owner { get; set; } - /// Slot-reuse counter of the projectile being removed. This MUST match the live - /// projectile's generation: the client resolves the key through Projectile.TryLookup, and - /// on a mismatch it allocates a brand new slot instead of clearing the offending one. + /// Slot-reuse counter. Must match the live projectile, or the client clears the wrong slot. public int Generation { get; set; } public override void Pack(Stream stream) diff --git a/TShockAPI/TSPlayer.cs b/TShockAPI/TSPlayer.cs index 3429cb66b5..f67ed53079 100644 --- a/TShockAPI/TSPlayer.cs +++ b/TShockAPI/TSPlayer.cs @@ -1740,10 +1740,7 @@ public void Spawn(int tilex, int tiley, PlayerSpawnContext context, int? respawn /// The projectile's owner. public void RemoveProjectile(int index, int owner) { - // `index` is the projectile's identity, not its slot. Since 1.4.5.7 the client keys - // projectiles on (spawner, identity, generation), so resolve the live projectile and - // reuse its generation - a stale one makes the client spawn a new blank projectile - // and leave the one we're trying to remove untouched. + // index is the identity, not the slot; a stale generation makes the client spawn a blank one int generation = 0; int slot = TShock.Utils.SearchProjectile((short)index, owner); if (slot >= 0 && slot < Main.maxProjectiles) @@ -1753,9 +1750,7 @@ public void RemoveProjectile(int index, int owner) } /// - /// Removes a projectile whose generation is already known - typically straight from the - /// packet that asked for it. Prefer this when rejecting a projectile the server has not - /// created yet, because the server-side lookup cannot resolve one that doesn't exist. + /// Removes a projectile whose generation is already known, e.g. straight from the packet. /// /// The projectile's identity. /// The player index of the projectile's owner. diff --git a/TShockAPI/Utils.cs b/TShockAPI/Utils.cs index c18509d126..3fb3ced81c 100644 --- a/TShockAPI/Utils.cs +++ b/TShockAPI/Utils.cs @@ -779,9 +779,7 @@ public bool TryParseTime(string str, out ulong seconds) /// projectile ID public int SearchProjectile(short identity, int owner) { - // 1.4.5.7 replaced the Projectile.identity field with ProjectileKey. The key's Index - // component is a client-local identity, NOT the server slot; Projectile.keyToIndex - // maps (spawner, identity) onto the real index in Main.projectile. + // key.Index is a client-local identity; keyToIndex maps (spawner, identity) -> real index if (identity < 0 || identity > 1000 || owner < 0 || owner > 255) return 1000; @@ -789,7 +787,7 @@ public int SearchProjectile(short identity, int owner) if (index < 0 || index >= Main.maxProjectiles) return 1000; - // keyToIndex entries are never cleared, so confirm the slot still holds this key. + // keyToIndex is never cleared, so verify the slot still holds this key var key = Main.projectile[index].key; if (key.Spawner == owner && key.Index == identity) return index; @@ -806,9 +804,7 @@ public int SearchProjectile(short identity, int owner) /// projectile ID public int SearchProjectile(short identity, int owner, int generation) { - // An identity is reused as soon as the client retires it, so matching only spawner and - // identity resolves a new projectile onto the slot its predecessor still occupies. - // Vanilla compares the whole key; the generation is what separates the two. + // generation separates a reused identity from its predecessor int index = SearchProjectile(identity, owner); if (index < 0 || index >= Main.maxProjectiles) return 1000; From 30b1608e19421053eb95c785349679fd894e4e54 Mon Sep 17 00:00:00 2001 From: bulkinglb <72748386+bulkinglb@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:38:26 +0200 Subject: [PATCH 09/12] style: fix comments --- TShockAPI/GetDataHandlers.cs | 11 ++--------- TShockAPI/Net/ProjectileRemoveMsg.cs | 2 +- TShockAPI/TSPlayer.cs | 4 ++-- TShockAPI/Utils.cs | 2 -- 4 files changed, 5 insertions(+), 14 deletions(-) diff --git a/TShockAPI/GetDataHandlers.cs b/TShockAPI/GetDataHandlers.cs index 21e841c13a..f3424e6dcc 100644 --- a/TShockAPI/GetDataHandlers.cs +++ b/TShockAPI/GetDataHandlers.cs @@ -3270,7 +3270,6 @@ private static bool HandleItemDrop(GetDataHandlerArgs args) var stacks = args.Data.ReadInt16(); var prefix = args.Data.ReadInt8(); BitsByte flags = args.Data.ReadInt8(); - // bits 0-1: NewItemOwnership (0 none, 1 reserveLocal, 2 grabDelayLocal, 3 grabDelayAll) var ownership = (byte)((flags[0] ? 1 : 0) | (flags[1] ? 2 : 0)); var noDelay = ownership <= 1; var type = args.Data.ReadInt16(); @@ -3292,7 +3291,7 @@ private static bool HandleItemDrop(GetDataHandlerArgs args) private static bool HandleItemOwner(GetDataHandlerArgs args) { - // dead since 1.4.5.7 (vanilla guards case 22 with netMode != 2); kept for old clients + // vanilla never runs case 22 on a server; kept for old clients var id = args.Data.ReadInt16(); var owner = args.Data.ReadInt8(); @@ -3317,7 +3316,6 @@ private static bool HandleNpcItemStrike(GetDataHandlerArgs args) private static bool HandleProjectileNew(GetDataHandlerArgs args) { - // 1.4.5.7: packed ProjectileKey replaces identity+owner, no trailing UUID short var key = (ProjectileKey)args.Data.ReadInt32(); byte owner = (byte)key.Spawner; short ident = (short)key.Index; @@ -3378,7 +3376,6 @@ private static bool HandleProjectileNew(GetDataHandlerArgs args) private static bool HandleNpcStrike(GetDataHandlerArgs args) { - // 1.4.5.7: byte slot + generation byte; ack is packet 162 (DamageNPCAck) short id = args.Data.ReadInt8(); var generation = args.Data.ReadInt8(); var dmg = args.Data.ReadInt16(); @@ -3438,20 +3435,18 @@ private static bool HandleNpcStrike(GetDataHandlerArgs args) private static bool HandleProjectileKill(GetDataHandlerArgs args) { - // 1.4.5.7: i32 ProjectileKey + kill position replace identity/owner var key = (ProjectileKey)args.Data.ReadInt32(); var killPos = args.Data.ReadVector2(); var ident = (short)key.Index; var owner = (byte)args.Player.Index; - // Index is 10 bits (0-1023), keyToIndex only maxProjectiles+1 wide, TryGet does not bounds check + // TryGet does not bounds check, and Index is wider than keyToIndex if (key.Index > Main.maxProjectiles) { TShock.Log.ConsoleDebug(GetString("GetDataHandlers / HandleProjectileKill rejected out of range projectile index {0}", args.Player.Name)); return true; } - // vanilla compares the generation too, so a stale key kills nothing if (!key.TryGet(out var killed) || !killed.active) { TShock.Log.ConsoleDebug(GetString("GetDataHandlers / HandleProjectileKill rejected stale projectile key {0}", args.Player.Name)); @@ -4348,10 +4343,8 @@ private static bool HandleHealOther(GetDataHandlerArgs args) private static bool HandleCatchNpc(GetDataHandlerArgs args) { - // 1.4.5.7: no trailing "who" byte, server uses the sender's index var npcID = args.Data.ReadInt16(); - // a crafted id would index out of bounds if (npcID < 0 || npcID >= Main.maxNPCs) { TShock.Log.ConsoleDebug(GetString("GetDataHandlers / HandleCatchNpc rejected out of range npc {0}", args.Player.Name)); diff --git a/TShockAPI/Net/ProjectileRemoveMsg.cs b/TShockAPI/Net/ProjectileRemoveMsg.cs index 279e32f5b6..21fa8c4fd7 100644 --- a/TShockAPI/Net/ProjectileRemoveMsg.cs +++ b/TShockAPI/Net/ProjectileRemoveMsg.cs @@ -30,7 +30,7 @@ public override PacketTypes ID public short Index { get; set; } public byte Owner { get; set; } - /// Slot-reuse counter. Must match the live projectile, or the client clears the wrong slot. + /// Slot-reuse counter from the sender's ProjectileKey. public int Generation { get; set; } public override void Pack(Stream stream) diff --git a/TShockAPI/TSPlayer.cs b/TShockAPI/TSPlayer.cs index f67ed53079..a3e41d9b46 100644 --- a/TShockAPI/TSPlayer.cs +++ b/TShockAPI/TSPlayer.cs @@ -1740,7 +1740,7 @@ public void Spawn(int tilex, int tiley, PlayerSpawnContext context, int? respawn /// The projectile's owner. public void RemoveProjectile(int index, int owner) { - // index is the identity, not the slot; a stale generation makes the client spawn a blank one + // index is the identity, not the slot int generation = 0; int slot = TShock.Utils.SearchProjectile((short)index, owner); if (slot >= 0 && slot < Main.maxProjectiles) @@ -1750,7 +1750,7 @@ public void RemoveProjectile(int index, int owner) } /// - /// Removes a projectile whose generation is already known, e.g. straight from the packet. + /// Removes a projectile whose generation is already known, typically taken from the packet. /// /// The projectile's identity. /// The player index of the projectile's owner. diff --git a/TShockAPI/Utils.cs b/TShockAPI/Utils.cs index 3fb3ced81c..26d9144c0f 100644 --- a/TShockAPI/Utils.cs +++ b/TShockAPI/Utils.cs @@ -779,7 +779,6 @@ public bool TryParseTime(string str, out ulong seconds) /// projectile ID public int SearchProjectile(short identity, int owner) { - // key.Index is a client-local identity; keyToIndex maps (spawner, identity) -> real index if (identity < 0 || identity > 1000 || owner < 0 || owner > 255) return 1000; @@ -804,7 +803,6 @@ public int SearchProjectile(short identity, int owner) /// projectile ID public int SearchProjectile(short identity, int owner, int generation) { - // generation separates a reused identity from its predecessor int index = SearchProjectile(identity, owner); if (index < 0 || index >= Main.maxProjectiles) return 1000; From 8dfbab5ddab77fc53e66780612f4849b555a58aa Mon Sep 17 00:00:00 2001 From: bulkinglb <72748386+bulkinglb@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:51:54 +0200 Subject: [PATCH 10/12] chore: bump OTAPI to 3.3.14 --- TShockLauncher/TShockLauncher.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TShockLauncher/TShockLauncher.csproj b/TShockLauncher/TShockLauncher.csproj index b5d1e997f9..07006bc0cb 100644 --- a/TShockLauncher/TShockLauncher.csproj +++ b/TShockLauncher/TShockLauncher.csproj @@ -38,7 +38,7 @@ - + PreserveNewest true From f1d023f62b11c126de944dc215f6cffd09e3e09c Mon Sep 17 00:00:00 2001 From: ZakFahey Date: Sat, 29 Aug 2026 10:56:42 -0700 Subject: [PATCH 11/12] Remove comments --- TShockAPI/GetDataHandlers.cs | 1 - TShockAPI/TSPlayer.cs | 1 - 2 files changed, 2 deletions(-) diff --git a/TShockAPI/GetDataHandlers.cs b/TShockAPI/GetDataHandlers.cs index f3424e6dcc..9a5cc34afa 100644 --- a/TShockAPI/GetDataHandlers.cs +++ b/TShockAPI/GetDataHandlers.cs @@ -3291,7 +3291,6 @@ private static bool HandleItemDrop(GetDataHandlerArgs args) private static bool HandleItemOwner(GetDataHandlerArgs args) { - // vanilla never runs case 22 on a server; kept for old clients var id = args.Data.ReadInt16(); var owner = args.Data.ReadInt8(); diff --git a/TShockAPI/TSPlayer.cs b/TShockAPI/TSPlayer.cs index a3e41d9b46..d926374ee4 100644 --- a/TShockAPI/TSPlayer.cs +++ b/TShockAPI/TSPlayer.cs @@ -1740,7 +1740,6 @@ public void Spawn(int tilex, int tiley, PlayerSpawnContext context, int? respawn /// The projectile's owner. public void RemoveProjectile(int index, int owner) { - // index is the identity, not the slot int generation = 0; int slot = TShock.Utils.SearchProjectile((short)index, owner); if (slot >= 0 && slot < Main.maxProjectiles) From 6a925a72f8c6778c1eef1a4c6592bd2c28030a84 Mon Sep 17 00:00:00 2001 From: bulkinglb <72748386+bulkinglb@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:57:04 +0200 Subject: [PATCH 12/12] chore: bump TSAPI submodule to 1.4.5.8 port --- TerrariaServerAPI | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TerrariaServerAPI b/TerrariaServerAPI index 9861e568d4..d4add121c2 160000 --- a/TerrariaServerAPI +++ b/TerrariaServerAPI @@ -1 +1 @@ -Subproject commit 9861e568d444e2c51e388b8c05f78c3628a6deea +Subproject commit d4add121c2463fe4dffe12ca7b20a4f057ea66b8