zoffline / zwift-offline

Use Zwift offline

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Ghosts feature

oldnapalm opened this issue · comments

I'm trying to implement ghosts feature in zoffline, it was requested by @scouseman in #35 (comment)

PlayerState structure is defined in https://github.com/Ogadai/zwift-mobile-api/blob/master/src/zwiftMessages.proto

The main issue I found (for now) is that rider position is not determined by x, y, altitude and heading, the only fields that seem to influence are roadTime and speed.

Found in https://github.com/wiedmann/zwift-line-monitor that it's an integer value from 5000 to 1005000, verified it by reading values sent from client to zoffline, but couldn't find a relation between roadTime and distance.

Values from a 5 km ride in Fuego Flats
https://github.com/oldnapalm/zwift-offline/blob/ghost/flats.csv

This is what I've tried so far
https://github.com/oldnapalm/zwift-offline/blob/ghost/udp.py

Any help or ideas are welcome.

Edit: this test is with a roadTime increment of 38 per meter. Around km 0.3 the ghost does some strange maneuvers, looks like it's when roadTime value becomes too off. It happens again around km 1.3

https://youtu.be/tdMKQu5L0tQ

Maybe the only way is storing roadTime values (one per meter?) for each segment?

Edit: maybe a better approach would be, instead of using FIT or GPX files for ghosts, making the UDP server store one full PlayerState record each 5 seconds during activities and save a file in player_id/ghosts/world_id/road_id directory. When the player gets on that road again (in the same direction) the server sends back the ghost. It would work only for new activities, but easier to implement.

Edit: here is my first try on the second approach
https://github.com/oldnapalm/zwift-offline/blob/ghost/udp2.py

Problems:

  • spawn location seems to be random
  • some routes leave a road and go back to it at a different location
  • need MAP_OVERRIDE to know in which world we are

Maybe it's a better idea to store the ghost for the entire activity, not per road.

Edit: this is the 3rd approach, my favorite so far. It saves ghosts in storage/player_id/ghosts and loads from storage/player_id/ghosts/load
https://github.com/oldnapalm/zwift-offline/blob/ghost/udp3.py

Merged the changes (3rd approach) into standalone.py in https://github.com/oldnapalm/zwift-offline/tree/ghost

How to use:

  • Create a file called "enable_ghosts.txt" inside storage folder
  • When rider stops, a ghost file will be saved in storage/player_id/ghosts
  • Copy a ghost file to storage/player_id and rename it to ghost.bin ghost files to storage/player_id/ghosts/load, on next ride they will be loaded (you can rename the files, e.g. Sands and Sequoias PR.bin)
  • You must ensure that the ghost files are from the same map and route you are going to ride

Issues:

  • random spawn position (although in Watopia usually not very far). In London this is a bigger problem, didn't test other maps yet added a delay as a possible workaround, ghost will start when you reach its spawn location
  • must do a full stop to save the ghost (no big deal, just can't close Zwift with rider moving) save ghost on Zwift exit
  • rely on TCP connection to clear ghost data (in case you restart Zwift without restarting zoffline) use a timeout instead

If someone wants to test this I can create a Windows release.

Here it goes

Edit: updated release, put ghost files in storage/player_id/ghosts/load

zoffline_1.0.49821_ghosts_test5.zip

In this test version you don't need to create the file enable_ghosts.txt

Remember to backup your files, mainly if you are updating from previous Zwift version, the database will be updated to fix segment timing issue.

https://youtu.be/4LAYMvOSGPU

Before testing please check my previous comment again, maybe I will update the release before the weekend.

Hi, thanks for the feedback.

Yes, that's expected because it's the way I found to know when to save the ghost (save when the rider stops). Need to find a better way to determine when to save the ghost. For now ghosts must be always moving.

I found another issue, if you change your weight, the W/Kg info for the ghost will be wrong, but it's just cosmetic, won't affect the ghost "performance".

Except that it's working fine for me.

https://youtu.be/b94Xwn387TM

Are you using the first release? I updated to make the ghost start when you reach it's spawn location. Another option is to give the ghost a few seconds to start moving, so you don't get too ahead.

About the save issue, probably going to make it save the ghost when the activity is saved (client calls /api/profiles/<int:player_id>/activities/<string:activity_id> with upload-to-strava argument.

Observed another issue where the ghost disappears and reappears. Happened arriving a hairpin turn, I'm guessing it's because the 3 seconds update frequency.

The client only calls /api/profiles/<int:player_id>/activities/<string:activity_id> with upload-to-strava argument after 5 km, so for now we will save ghosts on /api/users/logout. It will save when you exit Zwift, no matter if you click "save activity" or the trash bin.

This is a new test version
zoffline_1.0.49821_ghosts_test6.zip

Changes:

  • save ghost on Zwift exit
  • ghost will start when you pass its spawn position plus 15 seconds
  • if multiple ghosts, they will start when you pass the one in the back
  • if you spawn ahead of the ghost, it will start once you start pedaling

Other options I thought about:

  • ghost starts when you pass its spawn position (you can have an advantage if you pass it fast and the ghost takes time to gain speed)
  • with multiple ghosts, they start when you pass the one in the front

The disappear/reappear issue was in a test with 4 ghosts, it may also be related to draft between ghosts. If the ghost drafts and gets ahead of its recorded roadTime, at some point it will be too off and need to be relocated. Edit: it's probably related to drafting, removed 2 other ghosts and the problematic one started to behave normally.

Another question: when you stop during an activity, do you want the stopped time to be recorded or not? Right now only moving time is being saved to ghost file.

Ok, thanks for the testing and feedbacks.

I have 2 questions:

  • when you stop during an activity, do you want the stopped time to be recorded or not? Are these stops expected? Right now only moving time is being saved to ghost file.
  • if you are riding with a ghost and you stop, do you want the ghost to stop too, or continue moving?

I don't know, because I don't race, my workouts are usually under 1 hour and I rarely stop.

My wife said the ghosts should stop when you stop (she also doesn't use to stop, only in "emergencies"). Maybe it can be an option. She also thinks the stopped time should be disregarded when recording ghosts.

What do you think about recording the stopped time?

I agree with you, to simulate a race the ghost can't stop if you stop.

So I'm keeping the test6 behavior, stop time is disregarded when saving ghost, and ghost don't stop when you stop.

This is a new test release with minor changes:

  • Ghost will be saved on activity save (must have at least 5 km, like upload to Strava).
  • Removed the 15 seconds in ghost delay. If you spawn too close to the ghost it will have an advantage because it starts at full speed. If you spawn behind you can go slow until the ghost spawns to have a fair dispute (like if you spawn ahead, you can wait for the ghost to get closer).
  • Fixed a delay bug in roads that start in reverse direction (decreasing roadTime).

zoffline_1.0.49821_ghosts_test7.zip

Added the changes in #57

Please let me know if you find any issues. Thanks.

This is the bug I mentioned before
https://youtu.be/ABB4ayd8p9g

It happens when there are various ghosts. My guess is Zwift calculates draft between nearby players (ghosts in this case), at some point roadTime is too ahead of value received from server, the rider makes a loop, roadTime gets behind the value from server, then the rider disappears and reappears a little ahead, or takes a shortcut to reach roadTime from server.

The ghost feature has been merged into zoffline.

I'm closing this issue. Feel free to reopen it if you find anything else.

Cheers

The supposed drafting bug actually happened because of this misplaced increment 62a4161

When a ghost finished, others with higher id would get their id decremented.

I thought it too, but doing actual rides I noticed ghosts don't draft. We can draft from ghosts (unless using TT bike of course) but they just reproduce the recorded roadTime sequence, so it had to be something else.

Cheers

@scouseman did you ever noticed "duplicate" ghosts (like if you had 2 files with the same content)?

I observed this quite a few times on Windows, usually when I just turn on the computer and launch zoffline (e.g. there are 3 files and 4 ghosts appear, 2 of them stay always close to one another, like a shadow). It never happened on Mac.

I'm thinking maybe there's an issue with os.listdir on Windows.

Edit: I think it's better to use os.walk anyway, so we can organize the files in subdirs.

Thanks. Meanwhile I will test with os.walk/scandir.

When you have a chance please test this version

zoffline_1.0.51298_test.zip

Changes:

  • Use os.walk (which uses scandir) instead of listdir
  • Group ghosts on load (in case of multiple ghosts)

Small update, consider own start position when grouping ghosts

zoffline_1.0.51298_test2.zip

Another small update, checks if ghost is from same course, road and direction you are riding, so it should not be a problem if you leave files from other road/course in the load folder

zoffline_1.0.51298_test3.zip

Hi, great job! It gives a lot of fun to ride with ghosts :)
I've tried today some rides at Watopia Volcano and it worked fine. Once, with 4 ghosts, I couldn't find them after spawning, but after restarting Zwift next time they spawned just fine. Is is possible to have some single common spawn point on the map for ghosts? For example is it possible to detect when rider pass start line of the lap and record ride from that point until end of lap? that way it would be possible to record full laps and have single common spawn point for all ghosts.

Big thanks for all of you involved in zwift-offline and this ghosts feature, great job!

image

Hi @msobecki, thanks for reporting, glad you liked it.

Once, with 4 ghosts, I couldn't find them after spawning, but after restarting Zwift next time they spawned just fine.

If your machine is fast enough and depending on the last TCP update, it may be a problem if you restart Zwift too fast without restarting zoffline because there's a 25 seconds timeout for the TCP connection. Not sure if it's the cause of this issue, but may be. I also saw happening a few times the client failing to connect to the UDP server, couldn't find the reason, but a restart made it work again.

Is is possible to have some single common spawn point on the map for ghosts? For example is it possible to detect when rider pass start line of the lap and record ride from that point until end of lap? that way it would be possible to record full laps and have single common spawn point for all ghosts.

I think it's possible, but it would need some work to find the location (roadTime) of all starting points. This project could be useful https://github.com/wiedmann/zwift-line-monitor

Did you use the latest release or the test version from my previous comment above? The test version groups all ghosts when loading them. The negative side is that some ghosts will spawn already moving.

Once, with 4 ghosts, I couldn't find them after spawning, but after restarting Zwift next time they spawned just fine.
If your machine is fast enough and depending on the last TCP update, it may be a problem if you restart Zwift too fast without restarting zoffline because there's a 25 seconds timeout for the TCP connection. Not sure if it's the cause of this issue, but may be. I also saw happening a few times the client failing to connect to the UDP server, couldn't find the reason, but a restart made it work again.

This could be the reason, thanks for clarifying.

Is is possible to have some single common spawn point on the map for ghosts? For example is it possible to detect when rider pass start line of the lap and record ride from that point until end of lap? that way it would be possible to record full laps and have single common spawn point for all ghosts.
I think it's possible, but it would need some work to find the location (roadTime) of all starting points. This project could be useful https://github.com/wiedmann/zwift-line-monitor

Thanks, I'm not familiar with zwift protocol, I'll try to take a look at these projects.

Did you use the latest release or the test version from my previous comment above? The test version groups all ghosts when loading them. The negative side is that some ghosts will spawn already moving.

Yes, I've used latest test3 version. Didn't noticed any issues with spawning ghosts (just this one time without any ghost spawned), but today were my first rides with ghosts, so I cannot compare it with previous versions.

Great job! :)
BR

Is is possible to have some single common spawn point on the map for ghosts? For example is it possible to detect when rider pass start line of the lap and record ride from that point until end of lap? that way it would be possible to record full laps and have single common spawn point for all ghosts.

Here is something you can try

zoffline_1.0.51959_test.zip

This test version prints the current position when you are stopped. For testing, if you save this roadTime value in the file enable_ghosts.txt, ghosts will spawn at this point.

Capturar

When we have the start line values (course, roadID, isForward and roadTime) for various routes we create a database table.

Hi @oldnapalm, thanks for sharing this version!

Unfortunately I'm currently little busy with other things so I've only tried it once today on Volcano Circuit. I've noted roadTime of point after left turn next to spawn point (roadTime ~331000), put it in file, restarted zwift-offline and, to my surprise, ghosts spawned, like always, at regular spawn point instead of position filled in enable_ghosts. I've deserialized 4 saved ghost files that I have from this track and then realized, it might be not so easy. Based on these 4 files (each of them is ride from spawn point to start line and then one full lap), roadTime goes from ~540000-650000 at spawn point, it increases to about 900000 during next 30-40 seconds, then it suddenly changes to ~630000-680000 and starts to decrease for the next 25 seconds until it reaches ~100000. Then it changes to about 570000 and then goes up every next 3 seconds for the rest of the ride (rotating counter at about 1000000). Based on change of laps attribute, roadTime of start line is between 790709 and 791316. Maybe I messed up something with decoding ghosts .bin files, but time attribute seems to be ok. All rides were ~690 seconds long with full lap ~480 seconds.

So, I'm not so sure, if my first idea of spawning at specific point on the map was the best one.. ;)

BTW. Out of curiosity, wouldn't you mind sharing source code of changes introduced in this test version? for example on separate branch in your github repo of zwift-offline?

Best Regards,
Marcin

Hi @msobecki

I noticed that problem when testing in the desert. When roadTime reaches 1005000 it jumps back to 5000 and the start line is after that point, so my code didn't work.

This new test checks if current position is close to the defined spawn point (start line)

zoffline_1.0.51959_test2.zip

It loads this csv (put in storage folder)

https://github.com/oldnapalm/zwift-offline/blob/start-line/start_lines.csv

Captura de Tela 2020-06-14 às 11 36 12

The changed code is here oldnapalm@766c564

This one works in the desert, but there's also the situation you tested, in some routes (like the volcano) there are road changes and roadTime is different on each road. Maybe we need to check something else to get this working for all cases.

Also, I don't know if this 200 value is close enough
https://github.com/oldnapalm/zwift-offline/blob/67ff675eadbd231c2f4b7c79d246db6ee262e2f0/standalone.py#L271

The volcano circuit is a bit more complicated because the start line is not in the same road we spawn, so we need to store both IDs in the csv

https://github.com/oldnapalm/zwift-offline/blob/start-line/start_lines.csv

zoffline_1.0.51959_test3.zip

Changed source oldnapalm@77cb668

One issue with this approach is that as roadTime seems to be a percentage of the total road distance, it's hard to define a parameter to check if you are close to the start line. Notice I had to increase it to 3000 to work in volcano circuit, because it's a short road. It's worse because we save states each 3 seconds. Would need to save states every second or know the total road distance.

It worked for the 3 start lines in the csv (hilly route, sands and sequoias and volcano circuit), but needs tweaks to work for all routes.

Didn't test volcano circuit CCW but will most likely fail. We could store the start line direction, but we don't know which direction will be taken when you spawn, so you need to make sure the loaded files are for the route you are going to ride.

Thanks! Trying it right now :)

Video

Had to increase to 4000. 3000 failed for one file.

Maybe 5000 is a safe value. Is it close enough?

37358
27868
19868
11358
3054
3946
10197
18012
27125
35222
----------------------------------------
Exception happened during processing of request from ('127.0.0.1', 52312)
Traceback (most recent call last):
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/socketserver.py", line 650, in process_request_thread
    self.finish_request(request, client_address)
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/socketserver.py", line 360, in finish_request
    self.RequestHandlerClass(request, client_address, self)
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/socketserver.py", line 720, in __init__
    self.handle()
  File "standalone.py", line 269, in handle
    loadGhosts(recv.player_id, recv.state)
  File "standalone.py", line 109, in loadGhosts
    while abs(g.states[0].roadTime - start_rt) >= 3000:
IndexError: list index (0) out of range
----------------------------------------

Did some tests in the desert

https://youtu.be/nYzocir3klw

Would need to test on the other roads but 4000 looks good to me, what do you think?

Maybe a better approach would be storing the roadTime/distance ratio for each road.

Just realized we can get roadTime/distance from ghost data.

I believe this should work oldnapalm@6a8e2c2

Biggest difference in roadTime between two states near start line at Volcano Circuit was ~7700 (difference before last point before start and first point after), so 4000 should do the work.
On the other hand, maybe instead of finding nearest state to start line just take the first state after passing start line and deal with it, that ghosts will spawn a little bit after start line. States are saved every 3 seconds, so there always will be a little bit of inaccuracy.

Anyway, it gives a lot of fun to ride with ghosts so thanks again for you work :)

I just saw edit in your previous comment, will try to look at this commit and run this version tomorrow.

On the other hand, maybe instead of finding nearest state to start line just take the first state after passing start line and deal with it, that ghosts will spawn a little bit after start line.

It's a good idea but I couldn't think of a way to implement that. Would have to look for 2 adjacent points where one is > and other is < than start line. Also need to know when we pass the start line, and there's the roadTime "reset" situation (from 1005000 to 5000 and vice versa). Will think about it.

Test version with commit oldnapalm@6a8e2c2

zoffline_1.0.51959_test4.zip

Sorry still not had a chance to do any testing my return home has been delayed. As soon as I get back I will get right onto it

Don't worry, we are all doing this for fun ;)

@msobecki think this should work, check when pass the start line instead of when get close oldnapalm@77cef21

zoffline_1.0.51959_test5.zip

Need to test if will work in all cases.

If you want the ghosts to spawn further back, just remove this
https://github.com/oldnapalm/zwift-offline/blob/77cef21a27aac45547a5fff708c449d7bbf89817/standalone.py#L113

Great job @oldnapalm, I'll try to check it tomorrow, looks promising.
BTW. I can't check it now, but how often does zwift client send updates with player state to the server? Now state is saved every 3 seconds, does it make sense to change update_freq to, for example 2 seconds? or every second? It will make bin files bigger, but does it have any other negative consequences? any performance issues?

thanks

Don't know exactly, but it's more than once a second. You can change update_freq to 2 or 1.

https://github.com/oldnapalm/zwift-offline/blob/64888a75830f91aaf9af98cad54f8219c0726a77/standalone.py#L47

I believe there are no other negative consequences, just the bin files bigger. Files saved with different update_freq won't work.

If you will run from source, please use this branch, I made a few changes since the last blob https://github.com/oldnapalm/zwift-offline/blob/start-line/standalone.py

Another issue with the start line is when there are 2 or more routes with the same spawn point and different start lines (like jungle circuit and road to sky) you can't have both in the csv because we don't know which route you are going to ride when you spawn.

Once, with 4 ghosts, I couldn't find them after spawning, but after restarting Zwift next time they spawned just fine.

@msobecki if that happens again please check Zwift log for UDP errors or timeout

NETCLIENT:[ERROR] Error receiving UDP datagram [234] Existem mais dados disponíveis.

Not sure if it's the cause but I think we should disregard incoming packets if can't decode as ClientToServer

except:

Thanks @oldnapalm
Tried to test something yesterday but I had some issues with my environment. Compiled version test5 worked fine (running on the same machine as zwift). I tried latest version from your start-line branch but when I run it as standalone from sources on another computer, I couldn't even start zwift (problem with initial connection to the server), don't know why but it's for sure something in my environment, have to check it once again. Ended up with running docker image with mounted directory with latest sources as volume in image (+ another volume for storage) on another computer and I was able to connect, but no ghost spawned. Today I realized, that I didn't expose additional ports (3022, 3023), so I guess it explains why. I'll try to check my env in next days to be able to run it from sources.

Thanks again, BR.

when I run it as standalone from sources on another computer, I couldn't even start zwift (problem with initial connection to the server)

Maybe you didn't allow python.exe in Windows firewall?

Today I realized, that I didn't expose additional ports (3022, 3023), so I guess it explains why.

Just adding the ports in this line solves the problem or need something else? Thanks for that.

EXPOSE 443 80

Here's a bundle with the latest changes

zoffline_1.0.51959_test6.zip

And the updated csv (road to sky doesn't work, need to figure a way to fix)

https://github.com/oldnapalm/zwift-offline/blob/start-line/start_lines.csv

Added a few more start lines (untested). Removed jungle and desert (breaks ghost spawn for alpe and titans grove).

Another update, need to store the spawn direction in the csv because there are routes in both directions. Fixes desert and titans grove. Road to sky is still broken if jungle circuit start line is present. Other maps untested, just added the start lines, but should work.

https://github.com/oldnapalm/zwift-offline/blob/start-line/start_lines.csv

zoffline_1.0.51959_test7.zip

Update

zoffline_1.0.51959_test8.zip

Use spawn direction only when start line is different for each direction

https://github.com/oldnapalm/zwift-offline/blob/start-line/start_lines.csv

I think I found a bug! But on the other hand I'm just scratching the surface with this code so sharing thoughts here to see if I'm completely off base :)

Context: I've been working through an attempt to set up so that ghosts pick up at the start of the Alpe du Zwift climb, which has been tricky. Finally got it to work, but one of the things that gave trouble turned out to be the 'isForward' comparison when intaking the csv. As it's written, it's comparing the text strings of the isForward status, with the isForward indicator in the csv. Problem is, one is capitalized, other is not (based on the capitalization in existing rows of the csv) - so it was handling most fine because they were blank (so falling into the 'or not' condition), but those that were populated were trying to compare 'TRUE' to 'True' (for example), and thus thinking there was no matching starting line defined.

One super simple fix would be to just match cases in the csv file, but really that seems like leaving a vector for future errors if someone enters true/false in the wrong case at some future point. It seems like a better way would be to make a quick/simple boolean check like:

def booleans(v):
return v.lower() in ("yes", "true", "t", "1")

And then in the csv row check, update it to:

        rt = [t for t in sl if t[0] == str(course(state)) and t[1] == str(roadID(state)) and (booleans(t[2]) == booleans(str(isForward(state))) or not t[2])]

Basically just forces case and comparing to a list of strings that would evaluate to true, otherwise evaluate to false, creating a super-quick/rough string to boolean converter.

With the boolean/case check fixed, all worked :) To get road to sky + Alpe du Zwift working, I removed:

6,36,,35,546940,Jungle Circuit
And added:
6,36,True,43,22997,Road to Sky

With this result (success!):
https://youtu.be/5OjqQ7PGLIk

As I'm looking through this, I'm wondering if it wouldn't be pretty straightforward to do more of a segment-based ghost system... Going to do some experimentation, if I come up with any ideas solid enough to share I'll do so here.

Yes, the current code expects the csv to contain "True" or "False" (case sensitive).

Your booleans function could be

def booleans(v):
    if v[0].lower() in ['y', 't', '1']:
        return True
    return False

then the check could be booleans(t[2]) == isForward(state)

But in this specific case you don't need to check because the routes starting on this road are in the same direction, so you can leave it blank (falls in not t[2]).

Only Desert Flats/Titans Grove require this check because there are routes starting on the same road, going in both directions, with different starting points (if the start line is the same for both directions, like Downtown and UCI courses, it's also not necessary).

I think it would be simple to do a segment based ghost system. I tried a road based system in my early tests, but then I found that saving a ghost for the entire activity would be more useful to me. Tomorrow I can send you the code if you want to take a look.

Of course I'd love to take a look 😄

Aha! I see why I called it a bug: user error of course :)
-- When I opened the .csv for the first time, it opened in excel - And excel ... er... autocorrected 'True' to 'TRUE'

As you can see the csv reading is very basic, things can go wrong is the file is not perfectly formatted (e.g. start_rt = int(rt[0][4]) can cause a ValueError if there's a letter there).

I'm attaching the code I mentioned yesterday, but looking at it now I don't think it will help with your idea, it's very preliminar (e.g. used MAP_OVERRIDE instead of course for the first directory level). It's the second approach mentioned in the opening of this issue.

udp2.py.zip

Hello @scouseman @msobecki @defiancecp how are you?

Any of you are still using this or have many ghosts for a route?

I had an issue when I reached 13 ghosts for the same route, looks like it exceeds the UDP datagram size limit.

NETCLIENT:[ERROR] Error receiving UDP datagram [234] Existem mais dados disponíveis.

I tried to fix it by sending multiple messages with a maximum of 10 ghosts per message. It seems to be working for this specific case, but I would like to do some more tests, please let me know if you can help.

Thanks.

Hi @oldnapalm
Unfotunately, both my trainer and my laptop died some time ago, so I won't be able to help with testing right now.

But of course, again, thanks for your work and commitment 👍

After a few months of use and some bugs fixed, I changed the ghosts handling a bit, now it saves the files organized in <course>/<road> subdirectories (previously existing files will be organized) and ghosts will be loaded automatically (no need to copy to load directory, it was getting boring to manage the files manually).

Also added a checkbox in the launcher window to easily enable/disable the feature.