Creating a bounty hunt addon - meldavy/ipf-documentation GitHub Wiki
Long story short, I started addon development because both primary KTOS addon developers stopped playing the game, and they took down their addons with them. Many players share their addons through private communities, but if the original developers decided to take down their addons and make them no longer available, I think we should respect their decision.
One of the most used addons is an addon called quickpath, which is a navigation addon for bounty hunt. With the developer now gone, and with the nature of the addon using pre-designated hardcoded routes, any updates to the bounty hunt system could break this quickpath addon unless someone decides to maintain it.
But it's not of my interest to "maintain" someone else's addon if they wanted it to be taken down. It's also not of my interest to copy someone else's code, especially if that code was taken down.
For this addon, I start with 3 requirements:
- Addon will display a map, player's position, and where to go, until the user reaches destination map
- I will not use, reference, or even see existing
quickpathaddon source code. I will only be using extracted first-party IMC code as reference. - Unlike the original addon, I want my addon to not require any manual predesignated routes, and use some kind of shortest path graph traversal mechanism. But this would also require the graph to be built dynamically...
Research
To begin, I have to find how to do certain things:
- Display current map in a
picturecontrol (how to get map image name from map ID) - How to display player position on the map
- How to draw additional icon on the map
- How to get destination mapID
- How to find all adjacent maps of current map (or any given map)
How to get destination mapID
So this one is pretty simple, I just checked bountyhunt_milestone.lua source code and it was obvious:
function BOUNTYHUNT_MILESTONE_ON_INIT(addon, frame)
addon:RegisterMsg('BOUNTYHUNT_MILESTONE_OPEN', 'BOUNTYHUNT_MILESTONE_OPEN');
addon:RegisterMsg('BOUNTYHUNT_MILESTONE_CLOSE', 'BOUNTYHUNT_YESSCP_EXITMSGBOX');
end
function BOUNTYHUNT_MILESTONE_OPEN(frame, msg, strarg, numarg)
if numarg == 0 then return end
local mapId = tonumber(numarg)
...
end
How to get picture of current map
This was also a quick search:
-- from party.lua
local mapCls = GetClassByType("Map", locInfo.mapID);
SCR_SHOW_LOCAL_MAP(mapCls.ClassName, true, pos.x, pos.z);
...
-- from map.lua
function SCR_SHOW_LOCAL_MAP(zoneClassName, useMapFog, showX, showZ)
mappicturetemp:SetImage(zoneClassName);
Milestone #1: Displaying current map:
function ADDON_GAME_START(frame)
local curMapID = session.GetMapID()
local mapCls = GetClassByType("Map", curMapID);
local map = frame:GetChild("map")
AUTO_CAST(map)
map:SetImage(mapCls.ClassName)
end
Oops!!
Never forget the itemPic:SetEnableStretch(1);!!!

Player position on map
For this, I saw a message called MAP_CHARACTER_UPDATE in both minimap.lua and map.lua which seemed to place player pos on the map.
I took what seemed important and with a bit of trial and error:
-- on init
local my = frame:CreateOrGetControl("picture", "my", 84, 84, ui.LEFT, ui.TOP, 0, 0, 0, 0);
AUTO_CAST(my)
my:SetImage("minimap_leader")
-- on MAP_CHARACTER_UPDATE message
function TEMPLATE_CHARACTER_UPDATE(frame, msg, argStr, argNum)
local myHandle = session.GetMyHandle()
local map = frame:GetChild("map");
local pos = info.GetPositionInMap(session.GetMyHandle(), map:GetWidth(), map:GetHeight());
local my = frame:GetChild("my");
AUTO_CAST(my)
my:SetOffset(pos.x - my:GetImageWidth() / 2, pos.y - my:GetImageHeight() / 2);
local angle = info.GetAngle(myHandle)
my:SetAngle(angle);
map:Invalidate()
end

How to find all adjacent maps of current map
This was definitely the hardest part, as well as the most vital. My plan was to create a function that given a map ID, would return a list of all adjacent map IDs, and I'd be able to use this to perform shortest-length graph traversal to find the most ideal bounty path.
So for this, I started with a combination of stuff. First, function MAP_MAKE_NPC_LIST(frame, mapprop, npclist, statelist, questIESlist, questPropList, mapWidth, mapHeight, offsetX, offsetY) in map.lua had a lot of clues.
MAP_MAKE_NPC_LIST showed me how to get a list of "NPC"s given a mapID, and within those outputs were map warp markers as well.
This method also calls SET_MAP_MONGEN_NPC_INFO in lib_uiscp.lua, which also contains a lot of clues we need.
function SET_MAP_MONGEN_NPC_INFO(picture, mapprop, WorldPos, MonProp, npclist, statelist, questIESlist)
...
local iconOverride = MonProp:GetMinimapIcon();
end
Basically this shows that a MonProp object contains icon image name.
Then through trial and error, printing stuff to console, and matching what I was visually seeing in the game, I found out that iconName == 'minimap_portal' or iconName == 'minimap_erosion' are map warps.
Thus putting all this together, given a mapID, I can get the mongens of a map, filter out all mongens that are warps to other maps, and then get the name of the NPC (which would be the name of the map it leads to). This was based on worldmap2_minimap.lua sourcecode:
function WORLDMAP2_MINIMAP_NPC_INFO_ADD(frame, monProp, count)
...
local npcName = monProp:GetName()
end
Given the name of the npc, we can reverse lookup the Class of the object to get a classID. Full code looks like below:
function MyAddon.GetAdjacentMaps(self, mapName)
local adjacentMaps = {}
local mapprop = geMapTable.GetMapProp(mapName);
local mongens = mapprop.mongens;
local cnt = mongens:Count();
for i = 0 , cnt - 1 do
local MonProp = mongens:Element(i);
local iconName = MonProp:GetMinimapIcon()
if (iconName == 'minimap_portal' or iconName == 'minimap_erosion') then
local name = MonProp:GetName()
local mapCls = GetClassByStrProp("Map", "Name", name);
local clsName = TryGetProp(mapCls, "ClassName", "None");
adjacentMaps[clsName] = 1
end
end
return adjacentMaps
end
With initial testing, this worked fine, but then I ran into an issue!

Can you spot the issue? If you have a good eye, you might see that the NPC name has an extra space after the 4, while the actual map name does not!
Because of this, our reverse lookup mechanism fails and unfortunately I decided that this was not a good solution.
Attempt #2
While debugging the above issue, one of the things I had to do was dig into the IES files, and while doing so, I ran into this in map.ies:

It looked promising! For every map, there was a list of all connected maps with a / separator. So time to code:
local mapCls = GetClass("Map", mapName);
if (mapCls == nil) then
return {}
end
local adjacentMaps = {}
local linkedZone = TryGetProp(mapCls, "PhysicalLinkZone");
for match in linkedZone:gmatch("([^/]+)") do
adjacentMaps[match] = 1
end
However, we run into another issue! PhysicalLinkZone is actually apparently no longer used and the data inside it is out of date! There are entries that points to wrong maps that no longer exist, and this was even more painful to debug!
Because of this issue, again I had to scrap this idea.
Attempt #3
Aftering doing bit more experimentation, I went back to what I did for my first attempt:
function MyAddon.GetAdjacentMaps(self, mapName)
local adjacentMaps = {}
local mapprop = geMapTable.GetMapProp(mapName);
local mongens = mapprop.mongens;
local cnt = mongens:Count();
for i = 0 , cnt - 1 do
local MonProp = mongens:Element(i);
local dialog = MonProp:GetDialog();
...
end
end
The important bit is GetDialog().

After some experimentation, GetDialog() either returned the Dialog prop or Enter prop, based on what was available.
In our case, I was interested in the Enter prop:
FEDMIAN_PILGRIM46
While this example is not that obvious, the reason why I went into this rabbit hole was because for other maps, this value looked incredibly promising:

And while trying to search around for what this value is actually doing, I ran into warp.ies:

It seemed that this was exactly what I needed! I would take the dialog, and then lookup a Warp class, get the TargetZone value, and that would be my adjacent map class names!
local mapName = mapCls.ClassName
local mapprop = geMapTable.GetMapProp(mapName);
local mongens = mapprop.mongens;
local cnt = mongens:Count();
for i = 0 , cnt - 1 do
local MonProp = mongens:Element(i);
local iconName = MonProp:GetMinimapIcon()
if (iconName == 'minimap_portal' or iconName == 'minimap_erosion') then
local warpCls = GetClass("Warp", MonProp:GetDialog());
if (warpCls ~= nil) then
local targetZone = TryGetProp(warpCls, "TargetZone", "None");
if (targetZone ~= "None") then
adjacentMaps[targetZone] = 1
end
end
end
end
return adjacentMaps
However, this also had its own issues...
These are the warps available in Klaipeda:

And here are the values from Warp.ies:

Again, if you have a keen eye, you might see that the Dialog values sometimes have additional WS_ or WARP_ prefix, while that prefix doesn't exist in the actual warp name.
At this point, I kind of gave up with all of these ID mismatches everywhere in the tos client. So for now, I just implemented a small trick to drop the first prefix if a Warp with the given name cannot be found.
Final code:
function BountyHud.GetAdjacentMaps(self, mapName)
local mapCls = GetClass("Map", mapName);
if (mapCls == nil) then
return {}
end
local adjacentMaps = {}
local mapName = mapCls.ClassName
local mapprop = geMapTable.GetMapProp(mapName);
local mongens = mapprop.mongens;
local cnt = mongens:Count();
for i = 0 , cnt - 1 do
local MonProp = mongens:Element(i);
local iconName = MonProp:GetMinimapIcon()
if (iconName == 'minimap_portal' or iconName == 'minimap_erosion') then
local warpCls = GetClass("Warp", MonProp:GetDialog());
if (warpCls == nil) then
for match in MonProp:GetDialog():gmatch("[a-zA-Z]+_(.*)") do
warpCls = GetClass("Warp", match);
end
end
if (warpCls ~= nil) then
local targetZone = TryGetProp(warpCls, "TargetZone", "None");
if (targetZone ~= "None") then
adjacentMaps[targetZone] = 1
end
end
end
end
return adjacentMaps
end
You can check out the full project in my repo: