ramtzok1
-
Posts
50 -
Joined
-
Last visited
Posts posted by ramtzok1
-
-
A quick update:
The moving of files to the data folder did help the VFS error we were having, but now it's just back to error code number 0 and game freezes...Today is the presentation, and it's sad to end the project on this note,but we'll the best out of the situation. Thanks for everyone who tried to help us!
-
3 hours ago, (-_-) said:
enet is probably overkill. Try mongoose. (already provided with the 0ad source)
https://cesanta.com/docs/overview/intro.html
Create an instance somewhere. The C++ simulation is probably the wrong place. Create another interface for that I guess.
Interesting, but unfortunately we won't have time to commit to a server right now. Therefore we will stay with the Files...
-
Update: I've finally managed to upload the entire repo of our latest work,you can check it out yourself here :https://github.com/ram1660/0ad
If you have any questions feel free to ask!
-
Just now, (-_-) said:
You might want to try another approach for IPC. (Inter-process communication).
What was wrong with the client-server approach?
I tried to make an E-Net server-client, however I was confused about how to make the connection also my guide told me and to my partner to do not tarry on it if we are getting stuck and just stick to the idea of the files which made sense because it "worked" until we found all of these problems.
-
13 minutes ago, Stan` said:
Try a path without spaces (Just in case)
Yeah,It doesn't work. I guess it just defaults to everything in the simulation folder.
-
Well, I tried to create a dummy file and just see if I could read data from it, but apparently it is not working at all, regardless of using a file or anything else. Does anyone know what could be the reason for that? I'm pretty sure that is what happens too since the VFS_load (Whatever that is) is the same that was for us beforehand.
-
2 minutes ago, Stan` said:
I guess you could try an absolute path. One could also try in a mod in my documents there is a 0ad folder in my games
I guess those are generic error codes though you could check what calls the function at wdbg_sym.cpp line 848
Okay, should I try just using the absolute path into a folder i create in the mod folder in My games?
I'll also chekc the cpp file, thanks!
-
11 hours ago, Stan` said:
The game periodically checks for file changes and reload them if they changed. This allows you to change code in a script while the game is running for instance.
I see. I guess it one of the main folders of the game, and not our custom-made,right?
also, I did want to move the actual files to a place like My Documents, because I suspected a conflict early on, but as far as I understood the Engine.ReadFile and WriteFile wouldn't work with it, right? (The path needs to be like the one we used in the code examples above,and not absolute?)
Also, is there a place where I can check error codes? I tried to change the way we handle a time when JS tries to access the .txt, and got the following error code:Assertion failed: "el_size != 0" Location: wdbg_sym.cpp:848 (dump_sym_array) Call stack: While generating an error report, we encountered a second problem. Please be sure to report both this and the subsequent error messages. errno = 2 (Error during IO) OS error = 0 (no error code was set)
The changed code:
winningPetential = this.getMLData(); if(winningPetential == -1) // Basically, the file reading was unsuccessful. { aMin -= 1; continue }
-
1 hour ago, Stan` said:
I believe the issue is the file being hotloaded like the js scripts. I'm not sure exactly which folder is being hotloaded but moving it out of there and loading it only on demand might work.
Can you please explain what does hotloaded mean? I'm unfamiliar with the term.
1 hour ago, elexis said:Another common trick to avoid issues like that is to rename the file before doing something with it, so that the other process doesn't get into the opportunity of doing something to it.
Interesting idea! Is there a good way to rename a file in JS?
EDIT: I gave it some thought and I can't really see how it fits our code, since our objective is that the Python script will create a file and that file will be processed by the JavaScript.
-
15 hours ago, elexis said:
Wasn't the file truncated to zero bytes and then deleted or something? Like that being two steps that can be affected by concurrency, not only one?
It does speak of a different process though. And once python opens file access (regardless of exceptions), 0ad can't open that file for that time, no? It sounds like you dealt with the problem where python tries to open it when 0ad has it open already, but not the other way around.
What you say may be true, but unfortunately we tried to figure out to be sure and it didn't quite work yet.
Here is how we handle the file accessing, JS-side (defenseManger.js):
let i = 0; while(i < 100000) // delaying the JavaScript Side so python can catch-up. i++; if( Engine.isFileInUse("D:\\Users\\micha\\Desktop\\ram-and-michael\\binaries\\data\\mods\\public\\simulation\\ai\\petraML\\mlData\\answer.txt") && !Engine.FileExists("simulation/ai/petraML/mlData/answer.txt")) return -1; Engine.AppendToBuffer("a"); Engine.WriteToFile("simulation/ai/petraML/mlData/zxccxz.txt"); // dummy file to check if worked. let chance = undefined; do { warn("before reading ") chance = parseFloat(Engine.ReadFile("simulation/ai/petraML/mlData/answer.txt")); // place where error occurs. this.counter += 1; warn("COUNTER: " + this.counter.toString()) warn("READ CHANCE: " + chance.toString()) } while (chance === undefined); Engine.AppendToBuffer("a"); Engine.WriteToFile("simulation/ai/petraML/mlData/gameState.txt"); // for python to check if JS is done. return chance;
IsFileInUse() is a function that we created to check if a text file is in use, the code:
inline bool JSI_VFS::isFileInUse(ScriptInterface::CxPrivate* pCxPrivate, const std::wstring& filePath) { struct stat buffer; return (stat((char*)filePath.c_str(), &buffer) == 0); }
The only time we access Answer.txt in the python side is to write the output from our model. The only problem I can figure out regrading the one-sided solution is the fact that JS is a lot faster and just somehow manages to try and check if Answer.txt exists and in use before + reading it before it is even created.
NOTE: we also tried to use the ReadFile function on a dummy file that we created to see if reading just a regular file that is not accessed brings trouble, and we faced the same error code you see regarding the file reading in the main post. -
15 minutes ago, (-_-) said:
Trying to deal with this in Python code is easier than trying to do the same 0AD side.
42 minutes ago, elexis said:Last line of the log. So gotta find some mechanism to prevent two processes from reading and writing simultaneously to the same file (https://en.wikipedia.org/wiki/Mutual_exclusion#Types_of_mutual_exclusion_devices )
We dealt with that problem where there was a data race between Python and the game where they both tried to access the same file. We added exceptions handling on the Python side and making sure no other process is using the file.
I don't think the Python script is related to that problem any more.
As I said I will upload the whole project including the ML model so you will be able to see every step we are making and maybe spot where we did wrong.
- 1
-
1 hour ago, Stan` said:
What os are you ?
Windows handles multiple file ownership badly.
Do you have a set up guide so I can try to reproduce it locally
Me and my partner modified functions and code I think it won't be possible to reproduce without uploading the whole project to GitHub and getting Anaconda set up correctly on your side.
I will upload the project tomorrow.
- 1
-
I am on Windows. I can give you the files we modified + the script or share our repo if you would like!
-
EDIT: The repository https://github.com/ram1660/0ad
Hey guys, long time has passed,and I hoped I could resurface with a similar question to previous ones:
Me and my partner have continued working on our ML AI and on the brink of finishing, but one error is continuously bothering us: When reading the answer.txt file that contains the % of winning an engagement using Engine.ReadFile() in the assignDefenders function , we usually see an error in game that reads: " CVFSFile: file simulation/ai/petraML/mldata/answer.txt could not be opened(vfs_load: -110101)"later on, if it runs too much, it usually crashes with the following tracing:
Assertion failed: "hFile != INVALID_HANDLE_VALUE" Location: wfilesystem.cpp:285 (wtruncate) Call stack: wtruncate (wfilesystem.cpp:285) pathname = 0x00F8E0F4 -> path = (unsupported basic_string<wchar_t,char_traits<wchar_t> >) separator = [8] { 92 ('\'), 104 ('h'), 413, 57282, 57788, 248, 3176, 104 ('h') } length = 32327 (0x0000000000007E47) io::Store<io::DefaultCompletedHook,io::DefaultIssueHook> (io.h:315) pathname = 0x00F8E0F4 (see above) data = 0x39FDF000 size = 32327 (0x00007E47) p = 0x00F8E0D8 -> alignment = 1 (0x0000000000000001) blockSize = 0 (0x00000000) queueDepth = 1 (0x00000001) completedHook = 0x00F8E0F3 -> (io::DefaultCompletedHook) issueHook = 0x00F8E0F3 (see above) op = m_FileDescriptor = 6 (0x00000006) m_OpenFlag = 7 (0x00000007) m_Offset = 0 (0x0000000000000000) m_Size = 32327 (0x0000000000007E47) m_Buffer = 0x39FDF000 (see above) file = m_PathName = path = (unsupported basic_string<wchar_t,char_traits<wchar_t> >) separator = 92 ('\') m_FileDescriptor = 4294967295 (0xFFFFFFFF) m_OpenFlag = 1 (0x00000001) RealDirectory::Store (real_directory.cpp:57) this = (unavailable) name = 0x00F8E19C -> path = (unsupported basic_string<wchar_t,char_traits<wchar_t> >) separator = "/]ŵ�øg" fileContents = 0x00F8E200 -> (shared_ptr<unsigned char>) size = 32327 (0x00007E47) VFS::CreateFile (vfs.cpp:145) this = (unavailable) pathname = 0x00F8E218 -> path = (unsupported basic_string<wchar_t,char_traits<wchar_t> >) separator = [8] { 47 ('/'), 248, 62208, 14840, 57988, 248, 43 ('+'), 0 } fileContents = 0x00F8E200 (see above) size = 32327 (0x00007E47) s = (`anonymous-namespace'::ScopedLock) file = m_name = path = (unsupported basic_string<wchar_t,char_traits<wchar_t> >) separator = 16 m_size = 875861008 (0x34349410) m_mtime = 26288751805784504 (0x005D657B00F8E1B8) m_priority = 1 (0x00000001) m_loader = (shared_ptr<IFileLoader>) name = path = (unsupported basic_string<wchar_t,char_traits<wchar_t> >) separator = 47 ('/') directory = 0x0A0BC4BC -> m_files = (unsupported map<Path,VfsFile >) m_subdirectories = (unsupported map<Path,VfsDirectory >) m_realDirectory = (shared_ptr<RealDirectory>) m_shouldPopulate = 0 (0x00000000) JSI_VFS::WriteJSONFile (jsinterface_vfs.cpp:223) pCxPrivate = 0x1C545740 -> { pScriptInterface = 0x1C545740 (see above), pCBData = 0x17979FE0 } filePath = 0x00F8E284 -> (unsupported basic_string<wchar_t,char_traits<wchar_t> >) val1 = { (js::ValueOperations<JS::Handle<JS::Value> >) } ptr = 0x00F8E320 -> data = asBits = -515287411008 (0xFFFFFF88067A16C0) s = { payload = i32 = 108664512 (0x067A16C0) u32 = 108664512 (0x067A16C0) boo = 108664512 (0x067A16C0) str = 0x067A16C0 -> (JSString) sym = 0x067A16C0 (see above) obj = 0x067A16C0 (see above) cell = 0x067A16C0 (see above) ptr = 0x067A16C0 (see above) why = 108664512 word = 108664512 (0x067A16C0) uintptr = 108664512 (0x067A16C0) , tag = -120 } asDouble = -nan (0xFFFFFF88067A16C0) asPtr = 0x067A16C0 (see above) str = (unsupported basic_string<char,char_traits<char> >) path = path = (unsupported basic_string<wchar_t,char_traits<wchar_t> >) separator = 47 ('/') rq = mContext = 0x1AD21E18 -> (JSContext) val = { (js::ValueOperations<JS::Rooted<JS::Value> >) } stack = 0x1AD21E4C -> 0x00F8E208 -> (js::RootedBase<void *>) stack = 0x1AD21E4C (see above) prev = 0x00F8E29C -> (js::RootedBase<void *>) stack = 0x1AD21E4C (see above) prev = 0x00F8E810 -> (js::RootedBase<void *>) stack = 0x1AD21E4C (see above) prev = 0x00F8EBA4 -> (js::RootedBase<void *>) stack = 0x1AD21E4C (see above) prev = 0x00F8EC2C -> (js::RootedBase<void *>) stack = 0x1AD21E4C (see above) prev = 0x00F8ECE4 -> (js::RootedBase<void *>) stack = 0x1AD21E4C (see above) prev = 0x00000000 ptr = 0x06786A40 ptr = 0x00000000 ptr = 0x070D1220 ptr = 0x00000003 ptr = 0x00000000 ptr = 0x067A16C0 (see above) prev = 0x00F8E29C (see above) ptr = data = asBits = -515287411008 (0xFFFFFF88067A16C0) s = { payload = i32 = 108664512 (0x067A16C0) u32 = 108664512 (0x067A16C0) boo = 108664512 (0x067A16C0) str = 0x067A16C0 (see above) sym = 0x067A16C0 (see above) obj = 0x067A16C0 (see above) cell = 0x067A16C0 (see above) ptr = 0x067A16C0 (see above) why = 108664512 word = 108664512 (0x067A16C0) uintptr = 108664512 (0x067A16C0) , tag = -120 } asDouble = -nan (0xFFFFFF88067A16C0) asPtr = 0x067A16C0 (see above) buf = m_capacity = 32768 (0x00008000) m_data = (shared_ptr<unsigned char>) m_size = 32327 (0x00007E47) ScriptInterface::call<void,std::basic_string<wchar_t,std::char_traits<wchar_t>,std::allocator<wchar_t> >,JS::Handle<JS::Value>,&JSI_VFS::WriteJSONFile> (nativewrapperdefns.h:125) cx = 0x1AD21E18 (see above) argc = 2 (0x00000002) vp = 0x00F8E308 -> data = asBits = -514943345344 (0xFFFFFF881AFC1D40) s = { payload = i32 = 452730176 (0x1AFC1D40) u32 = 452730176 (0x1AFC1D40) boo = 452730176 (0x1AFC1D40) str = 0x1AFC1D40 -> (JSString) sym = 0x1AFC1D40 (see above) obj = 0x1AFC1D40 (see above) cell = 0x1AFC1D40 (see above) ptr = 0x1AFC1D40 (see above) why = 452730176 word = 452730176 (0x1AFC1D40) uintptr = 452730176 (0x1AFC1D40) , tag = -120 } asDouble = -nan (0xFFFFFF881AFC1D40) asPtr = 0x1AFC1D40 (see above) a0 = (unsupported basic_string<wchar_t,char_traits<wchar_t> >) rq = mContext = 0x1AD21E18 (see above) rval = { (js::ValueOperations<JS::Rooted<JS::Value> >) } stack = 0x1AD21E4C (see above) prev = 0x00F8E810 (see above) ptr = data = asBits = -541165879296 (0xFFFFFF8200000000) s = { payload = i32 = 0 (0x00000000) u32 = 0 (0x00000000) boo = 0 (0x00000000) str = 0x00000000 sym = 0x00000000 obj = 0x00000000 cell = 0x00000000 ptr = 0x00000000 why = JS_ELEMENTS_HOLE word = 0 (0x00000000) uintptr = 0 (0x00000000) , tag = -126 } asDouble = -nan (0xFFFFFF8200000000) asPtr = 0x00000000 typeConvRet0 = true 2C4CECD9 373971E8 js::jit::DoIteratorNewFallback (baselineic.cpp:11066) cx = (unavailable) frame = (unavailable) stub = (unavailable) value = (unavailable) res = (unavailable) errno = 0 (No error reported here) OS error = 32 (The process cannot access the file because it is being used by another process.)
It might not mean much, but after trying to figure out what causes this problem(running a ton of tests and creating a ton of simple,non-working functions in the engine scripts) we reached somewhat of a dead-end. It would mean so much if you guys could help us again regarding this issue.
Picture attached for reference. If you have any other questions you could review my previous threads and ask me directly.
We are going to present our work in Monday, and any help towards solving is deeply appreciated!
-
On 4/21/2019 at 7:27 PM, (-_-) said:
Something like this in the update function. It doesn’t wait for the file to proceed to the next update. It just checks each update. Non-blocking behavior. I don’t think the last function name is correct, but such a function does exist.
if (Engine.FileExist(..) && Engine.LastMtime(..) != g_lastReadTime)
this.chance = foo;
g_LastReadTime = Date.now(); // this wont be equal to last modified time, but you get the idea.
And yes, it really is an ugly hack probably.
Hi again!, I'm ram's partner and I was sitting on this now a bit more now. I had 2 questions in mind:
1.Wouldn't the second condition be always true? what is the first inital value of lastReadTime anyway?
2.When does the army.update func runs? How can we be sure it coincides with our editings to the Defense Manager itself?Thanks a lot!
-
15 hours ago, stanislas69 said:
Too bad you can't call CmpTimer... It executes stuff on each frame.
You got my attention, I want to know more about this function.
Can you please give me more details on where it is located? I couldn't find it in the code.
16 hours ago, (-_-) said:Something like this in the update function. It doesn’t wait for the file to proceed to the next update. It just checks each update. Non-blocking behavior. I don’t think the last function name is correct, but such a function does exist.
if (Engine.FileExist(..) && Engine.LastMtime(..) != g_lastReadTime)
this.chance = foo;
g_LastReadTime = Date.now(); // this wont be equal to last modified time, but you get the idea.
And yes, it really is an ugly hack probably.
I will try what you said and come back if I still get stuck, I also looked for the second function and couldn't find something with that name or references like: looking for "Last" or "Time" I also tried to look into any "scripting" folder, perhaps I missed it?
-
20 hours ago, (-_-) said:
So, the MLdata is used for assigning defenders based on the chance of success an army has right?
That's right.
20 hours ago, (-_-) said:I still think a better choice would be to have chance as a property. But not in DefManger but in DefArmy object. And have the chance updated for each army.
m.DefenseArmy.prototype.update would retrieve the chance value from the ML and update it.
And then, you can get winning potential for each army using a m.DefenseArmy.prototype.GetSuccessProbability; function.
So if I understand correctly, I need to move my sendDataToML and getMLData functions to defenseArmy, there, inside m.DefenseArmy.prototype.update after the for-loop I guess I send the army and getting the probability from the ML?
If so, I still run into the problem, the game stuck waiting for an answer.
20 hours ago, (-_-) said:This most likely needs changes to the whole stack though.
I don't fully understand what do you mean.
-
5 minutes ago, (-_-) said:
To clarify, those are simulation turns. Which is not the same as an AI turn.
An AI turn is every 8 simulation turns which translates to 4 seconds on multiplayer.
Regarding the actual code posted. I can only post something about it later today.
No problem.
-
9 minutes ago, (-_-) said:
I really don’t think you want blocking behavior there while the file is non-existent. Being in two different environments, that might lead to issues.
You might want to remove the loop and just call getMLData periodically. Every 5 turns or so. Which would be updating the chance property of def manager. So, rather than getMLData returning the chance, it updates the value and maybe you can add another method to retrieve the latest chance value in def manager.
(I lack some context so maybe I understood the problem wrong)
For your request here's some more information:
We are overriding the functions AssignDefenders and NeedsDefenders so if you compare between our PetraML and the regular Petra
m.DefenseManager.prototype.assignDefenders = function(gameState) { if (!this.armies.length) return; let armiesNeeding = []; // let's add defenders for (let army of this.armies) { this.sendDataToML(gameState, army); let needsDef = this.getMLData(); if (needsDef > 0.8) continue; let armyAccess; for (let entId of army.foeEntities) { let ent = gameState.getEntityById(entId); if (!ent || !ent.position()) continue; armyAccess = m.getLandAccess(gameState, ent); break; } if (!armyAccess) API3.warn(" PETRAML error: attacking army " + army.ID + " without access"); army.recalculatePosition(gameState); armiesNeeding.push({ "army": army, "access": armyAccess, "need": needsDef }); } if (!armiesNeeding.length) return; // Ram: DON'T DELETE THAT. // It helps us to gather our fighting units. // let's get our potential units let potentialDefenders = []; gameState.getOwnUnits().forEach(function(ent) { if (!ent.position()) return; if (ent.getMetadata(PlayerID, "plan") == -2 || ent.getMetadata(PlayerID, "plan") == -3) return; if (ent.hasClass("Support") || ent.attackTypes() === undefined) return; if (ent.hasClass("Catapult")) return; if (ent.hasClass("FishingBoat") || ent.hasClass("Trader")) return; if (ent.getMetadata(PlayerID, "transport") !== undefined || ent.getMetadata(PlayerID, "transporter") !== undefined) return; if (gameState.ai.HQ.victoryManager.criticalEnts.has(ent.id())) return; if (ent.getMetadata(PlayerID, "plan") !== undefined && ent.getMetadata(PlayerID, "plan") != -1) { let subrole = ent.getMetadata(PlayerID, "subrole"); if (subrole && (subrole == "completing" || subrole == "walking" || subrole == "attacking")) return; } potentialDefenders.push(ent.id()); }); let i = 0; for (let aMin = 0; aMin < armiesNeeding.length; aMin++) { let potentialEntity; i = 0; this.sendDataToML(gameState, armiesNeeding[aMin]); let winningPetential = this.getMLData(); if(winningPetential >= 0.8) { armiesNeeding.splice(aMin, 1); continue; } let potentialID = 0; while(winningPetential < 0.8 && potentialDefenders.length >= potentialID) { if(potentialDefenders[potentialID] === undefined) continue; potentialEntity = gameState.getEntityById(potentialDefenders[i]); let currDist = API3.SquareVectorDistance(potentialEntity.position(), armiesNeeding[a].army.foePosition); // Gets the current distance between the potential dif and army if(currDist > 40000) continue; armiesNeeding[aMin].addOwn(potentialDefenders[potentialID]); armiesNeeding[aMin].army.assignUnit(gameState, potentialDefenders[potentialID]); this.sendDataToML(gameState, armiesNeeding[aMin].army); potentialDefenders[potentialID] = undefined; potentialID++; winningPetential = this.getMLData(); } if(winningPetential >= 0.8) armiesNeeding.splice(aMin, 1); if(!armiesNeeding.length) { API3.warn("Everything is assigned!"); return; } if(potentialID >= potentialDefenders.length) { API3.warn("Out of defenders!"); break; } } // If shortage of defenders, produce infantry garrisoned in nearest civil centre let armiesPos = []; for (let a = 0; a < armiesNeeding.length; ++a) armiesPos.push(armiesNeeding[a].army.foePosition); gameState.ai.HQ.trainEmergencyUnits(gameState, armiesPos); };
now the variable needsDef has to be assigned to the probability because then we can't decide what to do with enemy's attack.
Do you still believe checking every 5 turns is still a good idea (I can't remember how much is a turn maybe 20ms?)?
I'm not sure if making property is the best because we are talking about the chance of each army.
-
Hello again!
As part of my ML project, I'm trying to read a txt file which contains a chance of PetraML to win the fight, now on the machine learning which is written in Python it calculates the possibility and returns it as a txt file as I mentioned and I'm trying the following code:
m.DefenseManager.prototype.getMLData = function() { warn("asdasdasdasdasdasd"); while (true) { if(Engine.FileExists("simulation/ai/petraML/mlData/answer.txt")) break; } warn("asdasdasdasdasdasd546545645665"); let chance = parseFloat(Engine.ReadFile("simulation/ai/petraML/mlData/answer.txt")); Engine.AppendToBuffer("a"); Engine.WriteToFile("simulation/si/petraML/mlData/gameState.txt"); return chance; };
The problem is the function is getting stuck inside the while true look, I looked inside Engine.FileExists function and found out it returns true the whole time but the game is still calling it for some reason.
Why do you need that?:
The ML receives a file loaded with the units and calculates the possibility to win and return it as an answer.txt file and in the meantime, this function is waiting for the file to arrive but as I said it's getting stuck there.
Is there a way to make this kind of loop? I need a way to transfer the data between the module and the game while the AI is waiting for an answer to come.
Thanks in advance!
-
1 hour ago, fatherbushido said:
Can you please revert this statement.
I provide you answers in PM.
You and/or your mate connect to irc. We answered the questions you asked us.
I am very disappointed.
Excuse me for not mentioning you, you did help via PM.
I didn't mean to hurt anybody, you did help me, every one of you did.
I can't remember exactly and also my partner about the irc, what happened there if any, a lot has changed since I appeared on your forums for the first time and really, thank you for everything.
@(-_-) @fatherbushido @stanislas69 @elexis @Imarok
I don't take any credit of solving my problems by myself. You did a lot of the job pointing me to the right direction.
-
1 minute ago, (-_-) said:
When exactly was this?
Before two months when we just started reading PETRA and leaving the idea to develop our own bot.
-
17 hours ago, stanislas69 said:
Can you attach you mod to this topic ? I might have an idea when looking at the code. Maybe it's just a folder name mismatch
I now noticed I missed your comment, sorry for that...
-
46 minutes ago, stanislas69 said:
@ramtzok1 Btw how did it go with the Fork AD developers did you get answer for all your questions ?
I tried to contact them via the IRC but they never answered back.
- 1
VFS Error while trying to read a file (JavaScript)
in Game Modification
Posted
It's fine. Not everything always pans out the way you want. We had great time though - chatting with you guys and researching all of the parts we needed was educating and the breakthroughs and help we got from you were beyond anything imaginable.
We also got certificates of excellence for the path we took while creating and planning everything, so there's that!