I have a fix for this. Here's the updated mod:
using System;
using System.Collections.Generic;
using System.Globalization;
using UnityEngine;
using System.IO;
using System.Text;
using Newtonsoft.Json;
using Oxide.Core;
using System.Text.RegularExpressions;
namespace Oxide.Plugins
{
[Info("LootLogs", "k1lly0u", "0.2.2"), Description("Log all items deposited and removed from storage, stash and oven containers")]
class LootLogs : RustPlugin
{
#region Fields
private readonly Hash<ItemId, LootData> m_TrackedItems = new Hash<ItemId, LootData>();
#endregion
private string SanitizeString(string input)
{
return Regex.Replace(input, @"[^a-zA-Z0-9]", "_");
}
#region Oxide Hooks
private void Init()
{
Unsubscribe(nameof(OnEntityDeath));
Unsubscribe(nameof(OnItemAddedToContainer));
Unsubscribe(nameof(OnItemRemovedFromContainer));
DeleteExpiredLogs();
}
private void OnServerInitialized()
{
Subscribe(nameof(OnEntityDeath));
Subscribe(nameof(OnItemAddedToContainer));
Subscribe(nameof(OnItemRemovedFromContainer));
}
protected override void LoadDefaultMessages()
{
lang.RegisterMessages(new Dictionary<string, string>
{
["Error.NoEntity"] = "No valid entity found!",
["Message.Details"] = "The box you are looking at is of the type: {0} with the ID: {1}. You can find the log for this box in 'oxide/logs/LootLogs/{0}/{2}_{1}.txt'"
}, this);
}
private void OnEntityDeath(StorageContainer entity, HitInfo hitInfo)
{
if (!entity || hitInfo == null)
return;
DeathLog(entity.GetType().Name,
entity.PrefabName,
entity.net.ID.Value.ToString(),
entity.transform.position.ToString(),
hitInfo.InitiatorPlayer ? hitInfo.InitiatorPlayer.displayName : string.Empty);
}
private void OnItemAddedToContainer(ItemContainer container, Item item)
{
if (item == null || !item.uid.IsValid)
return;
if (!container.entityOwner && !container.playerOwner)
return;
LootData lootData;
if (!m_TrackedItems.TryGetValue(item.uid, out lootData))
return;
if (container.playerOwner && container.playerOwner.net != null)
Log(container.playerOwner.displayName, $"{lootData.ItemAmount}x {lootData.ItemName}", lootData.Type, lootData.EntityID, lootData.EntityName, true);
if (container.entityOwner && !(container.entityOwner is DroppedItemContainer) && container.entityOwner.net != null)
Log(lootData.EntityName, $"{lootData.ItemAmount}x {lootData.ItemName}", container.entityOwner.GetType(), container.entityOwner.net.ID.Value, container.entityOwner.ShortPrefabName, false);
m_TrackedItems.Remove(item.uid);
}
private void OnItemRemovedFromContainer(ItemContainer container, Item item)
{
if (item == null || !item.uid.IsValid)
return;
if (!container.entityOwner && !container.playerOwner)
return;
if (!m_TrackedItems.ContainsKey(item.uid))
{
m_TrackedItems.Add(item.uid, new LootData
{
EntityName = container.entityOwner ? container.entityOwner.ShortPrefabName : container.playerOwner.displayName,
EntityID = container.entityOwner? container.entityOwner.net.ID.Value : container.playerOwner.userID,
ItemAmount = item.amount,
ItemName = item.info.displayName.english,
Type = container.entityOwner ? container.entityOwner.GetType() : typeof(BasePlayer)
});
timer.Once(5, () =>
{
if (m_TrackedItems.ContainsKey(item.uid))
m_TrackedItems.Remove(item.uid);
});
}
}
private struct LootData
{
public string EntityName;
public ulong EntityID;
public string ItemName;
public int ItemAmount;
public Type Type;
}
#endregion
#region Raycasts
private readonly RaycastHit[] m_RaycastHits = new RaycastHit[128];
private BaseEntity FindEntity(BasePlayer player)
{
int hits = Physics.RaycastNonAlloc(player.eyes.HeadRay(), m_RaycastHits, 5f);
if (hits > 0)
{
for (int i = 0; i < hits; i++)
{
RaycastHit raycastHit = m_RaycastHits[i];
BaseEntity baseEntity = raycastHit.GetEntity();
if (baseEntity is StorageContainer)
return baseEntity;
}
}
return null;
}
#endregion
#region Logging
private static readonly object _logFileLock = new object();
private static readonly string _directory = Path.Combine(Interface.Oxide.LogDirectory, "LootLogs");
private readonly Hash<string, Hash<string, List<string>>> m_QueuedLogs = new Hash<string, Hash<string, List<string>>>();
private const string DESTROYED_CONTAINERS = "DestroyedContainers";
private void QueueLogEntry(string path, string fileName, string text)
{
if (!m_QueuedLogs.ContainsKey(path))
m_QueuedLogs.Add(path, new Hash<string, List<string>>());
if (!m_QueuedLogs[path].ContainsKey(fileName))
m_QueuedLogs[path].Add(fileName, new List<string>());
m_QueuedLogs[path][fileName].Add(text);
}
private void OnServerSave()
{
try
{
foreach (KeyValuePair<string, Hash<string, List<string>>> path in m_QueuedLogs)
{
foreach (KeyValuePair<string, List<string>> fileName in path.Value)
{
foreach (string text in fileName.Value)
LogToFile(path.Key, fileName.Key, text);
fileName.Value.Clear();
}
}
}
catch(Exception ex)
{
PrintError($"{ex.Message}\n{ex.StackTrace}");
}
}
private void Log(string playername, string item, Type type, ulong id, string entityname, bool take)
{
string sanitizedPlayerName = SanitizeString(playername);
string sanitizedEntityName = SanitizeString(entityname);
string path = Path.Combine(type.Name, DateTime.Now.ToString("yyyy-MM-dd"));
string fileName = $"{sanitizedEntityName}_{id}";
QueueLogEntry(path, fileName, $"{sanitizedPlayerName} {(take ? "looted" : "deposited")} {item}");
}
private void DeathLog(string type, string entityname, string id, string position, string killer)
{
string sanitizedEntityName = SanitizeString(entityname);
string sanitizedKiller = SanitizeString(killer);
string path = Path.Combine(DESTROYED_CONTAINERS, DateTime.Now.ToString("yyyy-MM-dd"), type);
string fileName = $"DeathLog_{DateTime.Now.ToString("yyyy-MM-dd")}";
QueueLogEntry(path, fileName, $"Name:{sanitizedEntityName} | BoxID:{id} | Position:{position} | Killer: {sanitizedKiller} | LogFile: oxide/logs/LootLogs/{type}/{DateTime.Now.ToString("yyyy-MM-dd")}/{sanitizedEntityName}_{id}.txt");
}
private void LogToFile(string path, string filename, string text)
{
path = Path.Combine(_directory, path);
if (!Directory.Exists(path))
Directory.CreateDirectory(path);
filename = filename.ToLower() + ".txt";
lock (_logFileLock)
{
using (FileStream fileStream = new FileStream(Path.Combine(path, Utility.CleanPath(filename)), FileMode.Append, FileAccess.Write, FileShare.Read))
{
using (StreamWriter streamWriter = new StreamWriter(fileStream, Encoding.UTF8))
streamWriter.WriteLine(text);
}
}
}
private void DeleteExpiredLogs()
{
if (configData.DeleteAfterDays <= 0)
return;
if (!Directory.Exists(_directory))
return;
string[] directories = Directory.GetDirectories(_directory, "*", SearchOption.TopDirectoryOnly);
foreach (string subDirectory in directories)
DeleteExpiredLogFolders(subDirectory);
}
private void DeleteExpiredLogFolders(string subDirectory)
{
string[] dateDirectories = Directory.GetDirectories(subDirectory, "*", SearchOption.TopDirectoryOnly);
for (int i = 0; i < dateDirectories.Length; i++)
{
string date = Path.GetFileNameWithoutExtension(dateDirectories[i]);
try
{
DateTime dt = DateTime.ParseExact(date, "yyyy-MM-dd", CultureInfo.InvariantCulture);
if (DateTime.Now - dt > TimeSpan.FromDays(7))
{
Directory.Delete(dateDirectories[i], true);
}
}
catch(Exception ex){}
}
}
[ConsoleCommand("testlog")]
private void ConsoleCommandTestLog(ConsoleSystem.Arg arg)
{
if (arg.Connection != null)
return;
Log("string 1", "string 2", typeof(StorageContainer), 72223441141414, "string 5", false);
DeathLog("strings", "name", "id", "position", "killer");
OnServerSave();
}
#endregion
#region Chat Commands
[ChatCommand("findid")]
private void cmdFindID(BasePlayer player, string command, string[] args)
{
if (!player.IsAdmin)
return;
BaseEntity entity = FindEntity(player);
if (entity is StorageContainer)
FormatString(player, "Message.Details", entity.GetType(), entity.net.ID.Value, entity.ShortPrefabName);
else TranslatedString(player, "Error.NoEntity");
}
#endregion
#region Messaging
private void TranslatedString(BasePlayer player, string key) => player.ChatMessage(lang.GetMessage(key, this, player.UserIDString));
private void FormatString(BasePlayer player, string key, params object[] args) => player.ChatMessage(string.Format(lang.GetMessage(key, this, player.UserIDString), args));
#endregion
#region Config
private ConfigData configData;
private class ConfigData
{
[JsonProperty("Delete logs after X days (0 to disable)")]
public int DeleteAfterDays { get; set; }
public VersionNumber Version { get; set; }
}
protected override void LoadConfig()
{
base.LoadConfig();
configData = Config.ReadObject<ConfigData>();
if (configData.Version < Version)
UpdateConfigValues();
Config.WriteObject(configData, true);
}
protected override void LoadDefaultConfig() => configData = GetBaseConfig();
private ConfigData GetBaseConfig()
{
return new ConfigData
{
DeleteAfterDays = 0,
Version = Version
};
}
protected override void SaveConfig() => Config.WriteObject(configData, true);
private void UpdateConfigValues()
{
PrintWarning("Config update detected! Updating config values...");
if (configData.Version < new VersionNumber(0, 2, 0))
configData = GetBaseConfig();
configData.Version = Version;
PrintWarning("Config update completed!");
}
#endregion
}
}
Error with usernames with invalid characters -- FIXED
Thank you @TheProfessor for your work on providing a patch for this long-standing issue.
I have taken the above post of the entire file, and reduced it to a .patch file to see the difference/changes against version 0.2.1 more easily, and here it is minus the version bump.
However, a big warning: the patch changes too much. It sanitizes the names within the log files themselves, which is absolutely unwanted and undesired because it leads to confusion and issues when you are sifting (and/or grepping) through the log files trying to track down what a specific player did, most notably: you will not find them because the names have been sanitized and thus will not match your search string.
Therefore, I have revised and modified this original patch and reduced it to only what is exactly needed to fix the issues for the filenames, and leaving the names within the log files themselves untouched, as they should be. I will post this revised patch below this post.
--- 0.2.1/LootLogs.cs 2023-05-05 12:52:23.000000000 +0200
+++ 0.2.1-custom/LootLogs.cs 2025-06-13 18:59:11.342672300 +0200
@@ -6,6 +6,7 @@
using System.Text;
using Newtonsoft.Json;
using Oxide.Core;
+using System.Text.RegularExpressions;
namespace Oxide.Plugins
{
@@ -16,6 +17,11 @@
private readonly Hash<ItemId, LootData> m_TrackedItems = new Hash<ItemId, LootData>();
#endregion
+ private string SanitizeString(string input)
+ {
+ return Regex.Replace(input, @"[^a-zA-Z0-9]", "_");
+ }
+
#region Oxide Hooks
private void Init()
{
@@ -177,18 +183,22 @@
private void Log(string playername, string item, Type type, ulong id, string entityname, bool take)
{
+ string sanitizedPlayerName = SanitizeString(playername);
+ string sanitizedEntityName = SanitizeString(entityname);
string path = Path.Combine(type.Name, DateTime.Now.ToString("yyyy-MM-dd"));
- string fileName = $"{entityname}_{id}";
+ string fileName = $"{sanitizedEntityName}_{id}";
- QueueLogEntry(path, fileName, $"{playername} {(take ? "looted" : "deposited")} {item}");
+ QueueLogEntry(path, fileName, $"{sanitizedPlayerName} {(take ? "looted" : "deposited")} {item}");
}
private void DeathLog(string type, string entityname, string id, string position, string killer)
{
+ string sanitizedEntityName = SanitizeString(entityname);
+ string sanitizedKiller = SanitizeString(killer);
string path = Path.Combine(DESTROYED_CONTAINERS, DateTime.Now.ToString("yyyy-MM-dd"), type);
string fileName = $"DeathLog_{DateTime.Now.ToString("yyyy-MM-dd")}";
- QueueLogEntry(path, fileName, $"Name:{entityname} | BoxID:{id} | Position:{position} | Killer: {killer} | LogFile: oxide/logs/LootLogs/{type}/{DateTime.Now.ToString("yyyy-MM-dd")}/{entityname}_{id}.txt");
+ QueueLogEntry(path, fileName, $"Name:{sanitizedEntityName} | BoxID:{id} | Position:{position} | Killer: {sanitizedKiller} | LogFile: oxide/logs/LootLogs/{type}/{DateTime.Now.ToString("yyyy-MM-dd")}/{sanitizedEntityName}_{id}.txt");
}
private void LogToFile(string path, string filename, string text)
Merged post
This is the revised patch that only touches what is exactly needed to fix the issue with the filenames and leaves the names within the log files untouched as they should be:
--- 0.2.1/LootLogs.cs 2023-05-05 12:52:23.000000000 +0200
+++ 0.2.1-custom/LootLogs.cs 2025-06-13 18:43:50.169542100 +0200
@@ -6,6 +6,7 @@
using System.Text;
using Newtonsoft.Json;
using Oxide.Core;
+using System.Text.RegularExpressions;
namespace Oxide.Plugins
{
@@ -16,6 +17,11 @@
private readonly Hash<ItemId, LootData> m_TrackedItems = new Hash<ItemId, LootData>();
#endregion
+ private string SanitizeString(string input)
+ {
+ return Regex.Replace(input, @"[^a-zA-Z0-9]", "_");
+ }
+
#region Oxide Hooks
private void Init()
{
@@ -177,8 +183,9 @@
private void Log(string playername, string item, Type type, ulong id, string entityname, bool take)
{
+ string sanitizedEntityName = SanitizeString(entityname);
string path = Path.Combine(type.Name, DateTime.Now.ToString("yyyy-MM-dd"));
- string fileName = $"{entityname}_{id}";
+ string fileName = $"{sanitizedEntityName}_{id}";
QueueLogEntry(path, fileName, $"{playername} {(take ? "looted" : "deposited")} {item}");
}